From a6ac25cf84e4ecdc1961fc00e05dd162245bd8fd Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 18 Sep 2026 00:59:17 +0800 Subject: [PATCH] feat(agent): add task-oriented run logs and execution details --- skills/skills/langbot-mcp-ops/SKILL.md | 2 +- src/langbot/pkg/agent/runner/run_journal.py | 14 + .../pkg/agent/runner/run_ledger_store.py | 15 + src/langbot/pkg/api/http/service/agent.py | 26 +- src/langbot/pkg/api/mcp/server.py | 6 +- tests/unit_tests/agent/test_run_journal.py | 58 ++ .../unit_tests/agent/test_run_ledger_store.py | 27 + .../api/service/test_agent_service.py | 41 ++ .../app/home/agents/AgentDetailContent.tsx | 16 + .../agents/components/AgentMonitoringTab.tsx | 549 ++++++++++++++++++ web/src/app/infra/entities/api/index.ts | 16 +- web/src/i18n/locales/en-US.ts | 12 + web/src/i18n/locales/es-ES.ts | 12 + web/src/i18n/locales/ja-JP.ts | 12 + web/src/i18n/locales/ru-RU.ts | 12 + web/src/i18n/locales/th-TH.ts | 11 + web/src/i18n/locales/vi-VN.ts | 11 + web/src/i18n/locales/zh-Hans.ts | 10 + web/src/i18n/locales/zh-Hant.ts | 10 + web/tests/e2e/agent-monitoring.spec.ts | 231 ++++++++ 20 files changed, 1080 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/agent/test_run_journal.py create mode 100644 web/src/app/home/agents/components/AgentMonitoringTab.tsx create mode 100644 web/tests/e2e/agent-monitoring.spec.ts diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 0ccd50659..c793631b3 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -69,7 +69,7 @@ The tools wrap the LangBot service layer. Current tools (v1): | `list_bot_event_route_statuses` | Inspect bot event-route runtime status | | `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent, Pipeline and Event processor types | | `get_processor_metadata` | Discover installed event-capable Runner components, schemas and supported event patterns. | -| `list_processor_runs` / `get_processor_run_events` | Read one Event processor instance run history and logs; paginate with `before_id` / `after_sequence`. | +| `list_processor_runs` / `get_processor_run_events` | Read one Agent or plugin processor run history and logs; paginate with `before_id` / `after_sequence`. | | `debug_agent` | Execute a synthetic Agent event (`processor_uuid`, `payload`); requires `runtime.operate`. Returns final text and up to 1000 execution events (thinking, text, tool arguments/results). Platform tools use Mock; other configured tools execute normally. Optional `payload.mock`: `errors`/`results` keyed by platform tool name, `unsupported_apis` lists unavailable platform APIs. | | `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines | | `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers | diff --git a/src/langbot/pkg/agent/runner/run_journal.py b/src/langbot/pkg/agent/runner/run_journal.py index 2200e1160..dd2bfbf27 100644 --- a/src/langbot/pkg/agent/runner/run_journal.py +++ b/src/langbot/pkg/agent/runner/run_journal.py @@ -83,6 +83,7 @@ class AgentRunJournal: run_id=context['run_id'], event_id=event.event_id, binding_id=binding.binding_id, + agent_id=binding.agent_id, runner_id=descriptor.id, conversation_id=event.conversation_id, thread_id=event.thread_id, @@ -100,6 +101,19 @@ class AgentRunJournal: if binding.processor_type == 'event_processor' else {} ), + **( + { + **({'input_event': event.data} if event.event_type != 'message.received' else {}), + 'input': { + 'text': context.get('input', {}).get('text', ''), + 'contents': self._sanitize_contents(context.get('input', {}).get('contents', [])), + 'attachments': self._sanitize_attachments(context.get('input', {}).get('attachments', [])), + }, + 'delivery': event.delivery.model_dump(mode='json'), + } + if binding.processor_type == 'agent' + else {} + ), }, ) diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py index 76e9dc40a..0bcd88e11 100644 --- a/src/langbot/pkg/agent/runner/run_ledger_store.py +++ b/src/langbot/pkg/agent/runner/run_ledger_store.py @@ -636,6 +636,7 @@ class RunLedgerStore: strict_thread: bool = False, runner_id: str | None = None, binding_id: str | None = None, + agent_id: str | None = None, ) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]: """Page runs by scope. @@ -643,9 +644,21 @@ class RunLedgerStore: Tuple of (items, next_cursor, has_more, total_count). """ limit = min(max(int(limit), 1), 100) + agent_filter = sqlalchemy.or_( + AgentRun.agent_id == agent_id, + sqlalchemy.and_( + AgentRun.agent_id.is_(None), + sqlalchemy.or_( + AgentRun.binding_id.startswith(f'agent_{agent_id}_', autoescape=True), + AgentRun.binding_id.startswith(f'debug:{agent_id}:', autoescape=True), + ), + ), + ) async with self._session_factory() as session: # First get total count count_query = sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)) + if agent_id is not None: + count_query = count_query.where(agent_filter) if conversation_id is not None: count_query = count_query.where(AgentRun.conversation_id == conversation_id) if statuses: @@ -660,6 +673,8 @@ class RunLedgerStore: # Then get items query = sqlalchemy.select(AgentRun) + if agent_id is not None: + query = query.where(agent_filter) if conversation_id is not None: query = query.where(AgentRun.conversation_id == conversation_id) if statuses: diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 4c90177c8..8de6337c6 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -529,16 +529,20 @@ class AgentService: return config, component_ref, descriptor.supported_event_patterns async def get_processor_runs(self, context, processor_id, *, before_id=None): - """Read only this Workspace's explicitly created processor instance.""" + """Read only this Workspace's Agent or plugin processor runs.""" from ....agent.runner.run_ledger_store import RunLedgerStore processor = await self.get_agent(context, processor_id) - if processor is None or processor.get('kind') != AGENT_KIND_EVENT_PROCESSOR: - raise ValueError('Event processor not found') + if processor is None or processor.get('kind') not in {'agent', AGENT_KIND_EVENT_PROCESSOR}: + raise ValueError('Processor not found') store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine()) items, cursor, has_more, total = await store.list_runs( workspace_id=require_workspace_uuid(context), - binding_id=f'event_processor:{processor_id}', + **( + {'binding_id': f'event_processor:{processor_id}'} + if processor.get('kind') == AGENT_KIND_EVENT_PROCESSOR + else {'agent_id': processor_id} + ), before_id=before_id, ) return {'items': items, 'next_cursor': cursor, 'has_more': has_more, 'total': total} @@ -548,14 +552,22 @@ class AgentService: from ....agent.runner.run_ledger_store import RunLedgerStore processor = await self.get_agent(context, processor_id) - if processor is None or processor.get('kind') != AGENT_KIND_EVENT_PROCESSOR: - raise ValueError('Event processor not found') + if processor is None or processor.get('kind') not in {'agent', AGENT_KIND_EVENT_PROCESSOR}: + raise ValueError('Processor not found') store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine()) run = await store.get_run(run_id) if ( run is None or run.get('workspace_id') != require_workspace_uuid(context) - or run.get('binding_id') != f'event_processor:{processor_id}' + or not ( + run.get('binding_id') == f'event_processor:{processor_id}' + if processor.get('kind') == AGENT_KIND_EVENT_PROCESSOR + else run.get('agent_id') == processor_id + or ( + run.get('agent_id') is None + and str(run.get('binding_id', '')).startswith((f'agent_{processor_id}_', f'debug:{processor_id}:')) + ) + ) ): raise ValueError('Processor run not found') items, next_cursor, _, has_more = await store.page_run_events( diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 88a6e17c7..c4ece1a9e 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -227,14 +227,16 @@ class LangBotMCPServer: return _dump(await ap.agent_service.get_agent_metadata(context)) @mcp.tool( - description='List one Event processor instance run history; use before_id to page older runs. ' + description='List one Agent or plugin processor run history; use before_id to page older runs. ' 'created_at_ms, started_at_ms and finished_at_ms are Host lifecycle times in epoch milliseconds.' ) async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str: context = _authorized(Permission.RESOURCE_VIEW) return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id)) - @mcp.tool(description='Read logs and action results for an Event processor run; page using after_sequence.') + @mcp.tool( + description='Read logs and action results for an Agent or plugin processor run; page using after_sequence.' + ) async def get_processor_run_events( processor_uuid: str, run_id: str, diff --git a/tests/unit_tests/agent/test_run_journal.py b/tests/unit_tests/agent/test_run_journal.py new file mode 100644 index 000000000..8908681ba --- /dev/null +++ b/tests/unit_tests/agent/test_run_journal.py @@ -0,0 +1,58 @@ +"""Agent monitoring snapshots retain useful input without duplicating binary payloads.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.agent.runner.run_journal import AgentRunJournal + + +@pytest.mark.asyncio +@pytest.mark.parametrize('event_type', ['message.received', 'group.member_joined']) +async def test_agent_run_snapshot_records_identity_and_redacts_inline_attachments(event_type): + journal = AgentRunJournal(SimpleNamespace()) + ledger = SimpleNamespace(create_run=AsyncMock(return_value={})) + journal._run_ledger_store = ledger + raw_input = { + 'text': 'Describe the attached image', + 'contents': [{'type': 'image_base64', 'image_base64': 'large-image-data'}], + 'attachments': [{'name': 'notes.txt', 'content': 'large-file-data'}], + } + event = SimpleNamespace( + event_id='event-1', + conversation_id='conversation-1', + thread_id=None, + workspace_id='workspace-1', + bot_id='bot-1', + event_type=event_type, + data={'user_id': 'user-1', 'group_id': 'group-1'}, + source='platform', + delivery=SimpleNamespace(model_dump=lambda **kwargs: {'target_id': 'group-1'}), + ) + binding = SimpleNamespace( + binding_id='agent_agent-1_runner-1', + agent_id='agent-1', + processor_id='agent-1', + processor_type='agent', + ) + await journal.create_run( + event=event, + binding=binding, + descriptor=SimpleNamespace(id='runner-1'), + context={'run_id': 'run-1', 'input': raw_input}, + authorization={}, + ) + saved = ledger.create_run.call_args.kwargs + assert saved['agent_id'] == 'agent-1' + assert saved['metadata']['input']['text'] == raw_input['text'] + assert saved['metadata']['input']['contents'][0]['image_base64'] is None + assert saved['metadata']['input']['attachments'][0]['content'] is None + assert saved['metadata']['delivery'] == {'target_id': 'group-1'} + assert raw_input['contents'][0]['image_base64'] == 'large-image-data' + assert raw_input['attachments'][0]['content'] == 'large-file-data' + + if event_type != 'message.received': + assert saved['metadata']['input_event'] == event.data + else: + assert 'input_event' not in saved['metadata'] diff --git a/tests/unit_tests/agent/test_run_ledger_store.py b/tests/unit_tests/agent/test_run_ledger_store.py index 64684fc12..4359d87c5 100644 --- a/tests/unit_tests/agent/test_run_ledger_store.py +++ b/tests/unit_tests/agent/test_run_ledger_store.py @@ -472,3 +472,30 @@ async def test_run_lifecycle_retains_milliseconds(store, monkeypatch): await store.finalize_run(run_id='run-ms', status='completed') saved = await store.get_run('run-ms') assert saved['finished_at_ms'] - saved['started_at_ms'] == 275 + + +@pytest.mark.asyncio +async def test_agent_run_pages_include_old_bindings_without_leaking_other_agents(store): + for run_id, agent_id, binding, workspace in [ + ('old', None, 'agent_one_plugin:a/old/default', 'w'), + ('debug', None, 'debug:one:plugin:a/new/default', 'w'), + ('new', 'one', 'new-binding', 'w'), + ('other', 'two', 'agent_two_plugin:a/old/default', 'w'), + ('conflicting-id', 'two', 'agent_one_plugin:a/old/default', 'w'), + ('other-workspace', 'one', 'agent_one_plugin:a/old/default', 'other'), + ('plugin-processor', None, 'event_processor:one', 'w'), + ]: + await store.create_run( + run_id=run_id, + event_id=None, + agent_id=agent_id, + binding_id=binding, + workspace_id=workspace, + runner_id='runner', + ) + page, cursor, more, total = await store.list_runs(agent_id='one', workspace_id='w', limit=2) + assert [run['run_id'] for run in page] == ['new', 'debug'] + assert more and total == 3 + page, cursor, more, total = await store.list_runs(agent_id='one', workspace_id='w', before_id=cursor, limit=2) + assert [run['run_id'] for run in page] == ['old'] + assert not more and total == 3 diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py index 7ba3e5c9b..2f1889275 100644 --- a/tests/unit_tests/api/service/test_agent_service.py +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -905,3 +905,44 @@ async def test_processor_trace_rejects_other_workspace_or_instance(monkeypatch, with pytest.raises(ValueError, match='Processor run not found'): await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run') store.page_run_events.assert_not_called() + + +async def test_agent_runs_use_workspace_and_stable_agent_identity(monkeypatch): + service = AgentService(_make_app()) + service.get_agent = AsyncMock(return_value={'kind': 'agent'}) + service.ap.persistence_mgr.get_db_engine = Mock() + store = SimpleNamespace(list_runs=AsyncMock(return_value=([], None, False, 0))) + monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store)) + assert (await service.get_processor_runs(WORKSPACE_UUID, 'one', before_id=12))['total'] == 0 + store.list_runs.assert_awaited_once_with(workspace_id=WORKSPACE_UUID, agent_id='one', before_id=12) + + +@pytest.mark.parametrize( + 'binding,agent_id,workspace,allowed', + [ + ('agent_one_plugin:a/old/default', None, WORKSPACE_UUID, True), + ('debug:one:plugin:a/new/default', None, WORKSPACE_UUID, True), + ('any-new-binding', 'one', WORKSPACE_UUID, True), + ('agent_two_plugin:a/old/default', None, WORKSPACE_UUID, False), + ('debug:two:plugin:a/new/default', None, WORKSPACE_UUID, False), + ('agent_one_plugin:a/old/default', 'two', WORKSPACE_UUID, False), + ('agent_one_plugin:a/old/default', None, 'other-workspace', False), + ('event_processor:one', None, WORKSPACE_UUID, False), + ], +) +async def test_agent_trace_authorizes_history_across_runner_changes(monkeypatch, binding, agent_id, workspace, allowed): + service = AgentService(_make_app()) + service.get_agent = AsyncMock(return_value={'kind': 'agent'}) + service.ap.persistence_mgr.get_db_engine = Mock() + store = SimpleNamespace( + get_run=AsyncMock(return_value={'workspace_id': workspace, 'binding_id': binding, 'agent_id': agent_id}), + page_run_events=AsyncMock(return_value=([], None, None, False)), + ) + monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store.RunLedgerStore', Mock(return_value=store)) + if allowed: + await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run', after_sequence=100) + store.page_run_events.assert_awaited_once_with(run_id='run', after_sequence=100, limit=100) + else: + with pytest.raises(ValueError, match='Processor run not found'): + await service.get_processor_run_events(WORKSPACE_UUID, 'one', 'run') + store.page_run_events.assert_not_called() diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index 0693f1e24..5a5a8c6e9 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -27,6 +27,7 @@ import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent'; import PluginProcessorDetailContent from './PluginProcessorDetailContent'; import AgentCreateContent from './components/AgentCreateContent'; import AgentDebugPanel from './components/AgentDebugPanel'; +import AgentMonitoringTab from './components/AgentMonitoringTab'; import AgentFormComponent, { AgentFormHandle, RunnerStatus, @@ -281,6 +282,21 @@ export default function AgentDetailContent({ ) : undefined } unsavedLabel={t('pipelines.unsavedChanges')} + monitoring={ + currentWorkspace?.permissions.includes('resource.view') + ? { + label: t('pipelines.monitoring.title'), + workbenchLabel: t('pipelines.monitoring.workbench'), + content: ( + + ), + } + : undefined + } /> )} + typeof value === 'string' ? value : JSON.stringify(value, null, 2); +const textClass = + 'whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm'; + +export default function AgentMonitoringTab({ + agentId, + platformTools, +}: { + agentId: string; + platformTools: AgentPlatformTool[]; +}) { + const { t } = useTranslation(); + const [runs, setRuns] = useState([]); + const [total, setTotal] = useState(); + const [cursor, setCursor] = useState(null); + const [selectedId, setSelectedId] = useState(); + const [selected, setSelected] = useState(); + const [events, setEvents] = useState([]); + const [eventCursor, setEventCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [loadingTrace, setLoadingTrace] = useState(false); + const [listError, setListError] = useState(false); + const [traceError, setTraceError] = useState(false); + const [paging, setPaging] = useState(false); + const [traceRevision, setTraceRevision] = useState(0); + const generation = useRef(0); + const listBusy = useRef(false); + const listInitialized = useRef(false); + const traceBusy = useRef(false); + const alive = useRef(true); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + generation.current += 1; + }; + }, []); + + const refresh = useCallback(async () => { + if (listBusy.current) return; + listBusy.current = true; + try { + const page = await httpClient.getProcessorRuns(agentId); + if (!alive.current) return; + setRuns((current) => { + const merged = new Map(page.items.map((run) => [run.run_id, run])); + current.forEach((run) => { + if (!merged.has(run.run_id)) merged.set(run.run_id, run); + }); + return [...merged.values()]; + }); + if (!listInitialized.current) { + setCursor(page.has_more ? page.next_cursor : null); + listInitialized.current = true; + } + setTotal(page.total); + setSelectedId((current) => current ?? page.items[0]?.run_id); + setListError(false); + } catch { + if (alive.current) setListError(true); + } finally { + listBusy.current = false; + if (alive.current) setLoading(false); + } + }, [agentId]); + + useEffect(() => { + void refresh(); + const timer = window.setInterval(() => { + if (!document.hidden) void refresh(); + }, 5000); + return () => window.clearInterval(timer); + }, [refresh]); + + useEffect(() => { + if (!selectedId) return; + const version = ++generation.current; + setEvents([]); + setSelected(undefined); + setEventCursor(null); + setLoadingTrace(true); + setPaging(false); + setTraceError(false); + traceBusy.current = false; + void httpClient + .getProcessorRunEvents(agentId, selectedId) + .then((page) => { + if (generation.current !== version) return; + setSelected(page.run); + setEvents(page.items); + setEventCursor(page.has_more ? page.next_cursor : null); + }) + .catch(() => { + if (generation.current === version) setTraceError(true); + }) + .finally(() => { + if (generation.current === version) setLoadingTrace(false); + }); + return () => { + generation.current += 1; + }; + }, [agentId, selectedId, traceRevision]); + + const nextEvents = useCallback(async () => { + if (!selectedId || traceBusy.current || loadingTrace) return; + const version = generation.current; + traceBusy.current = true; + setPaging(true); + try { + const page = await httpClient.getProcessorRunEvents( + agentId, + selectedId, + eventCursor ?? events.at(-1)?.sequence, + ); + if (generation.current !== version) return; + setSelected(page.run); + setEvents((current) => [ + ...new Map( + [...current, ...page.items].map((event) => [event.sequence, event]), + ).values(), + ]); + setEventCursor(page.has_more ? page.next_cursor : null); + setTraceError(false); + } catch { + if (generation.current === version) setTraceError(true); + } finally { + if (generation.current === version) { + traceBusy.current = false; + setPaging(false); + } + } + }, [agentId, selectedId, eventCursor, events, loadingTrace]); + + useEffect(() => { + if ( + !selected || + !activeStatuses.has(selected.status) || + eventCursor !== null + ) + return; + const timer = window.setInterval(() => { + if (!document.hidden) void nextEvents(); + }, 3000); + return () => window.clearInterval(timer); + }, [selected, eventCursor, nextEvents]); + + async function moreRuns() { + if (cursor === null || listBusy.current) return; + listBusy.current = true; + setLoading(true); + try { + const page = await httpClient.getProcessorRuns(agentId, cursor); + if (!alive.current) return; + setRuns((current) => [ + ...new Map( + [...current, ...page.items].map((run) => [run.run_id, run]), + ).values(), + ]); + setCursor(page.has_more ? page.next_cursor : null); + setListError(false); + } catch { + if (alive.current) setListError(true); + } finally { + listBusy.current = false; + if (alive.current) setLoading(false); + } + } + + const labels = Object.fromEntries( + platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]), + ); + const steps = executionSteps(events); + const duration = selected ? processorRunDuration(selected) : null; + const failureReason = + selected?.status_reason || + events.findLast((event) => event.type === 'run.failed')?.data.error; + const models = [ + ...new Set( + events.flatMap((event) => { + const message = event.data.message ?? event.data.chunk; + return message && + typeof message === 'object' && + 'model' in message && + typeof message.model === 'string' && + message.model + ? [message.model] + : []; + }), + ), + ]; + const stateLabel = (run: ProcessorRun) => + t( + `agents.eventProcessor.status_${({ created: 'pending', claimed: 'queued' } as Record)[run.status] ?? run.status}`, + { + defaultValue: run.status, + }, + ); + const debugRun = (run: ProcessorRun) => + run.binding_id?.startsWith('debug:') || run.metadata.source === 'webui'; + + return ( +
+
+

+ {t('agents.monitoring.description')} +

+ +
+ {listError && ( + + {t('monitoring.loadError')} + + )} +
+
+

+ {t('agents.eventProcessor.runs')} + {total !== undefined && ( + {total} + )} +

+
+ {runs.map((run) => ( + + ))} + {!runs.length && ( +

+ {loading ? t('common.loading') : t('agents.monitoring.empty')} +

+ )} + {cursor !== null && ( + + )} +
+
+
+ {loadingTrace ? ( +

{t('common.loading')}

+ ) : ( + selected && ( + <> +
+
+

+ {eventPatternLabel(selected.metadata.event_type ?? '', t)} +

+ + {stateLabel(selected)} + + {debugRun(selected) && ( + {t('agents.debugTab')} + )} +
+
+ + {duration !== null && ( + + {t('monitoring.llmCalls.duration')}:{' '} + {formatRunDuration(duration)} + + )} + {models.length > 0 && {models.join(', ')}} + {selected.usage?.total_tokens != null && ( + + {t('monitoring.llmCalls.totalTokens')}:{' '} + {selected.usage.total_tokens.toLocaleString()} + + )} +
+

+ {selected.runner_id} +

+
+ {failedStatuses.has(selected.status) && failureReason && ( + + + {json(failureReason)} + + + )} + + + + {t('agents.monitoring.input')} + + + + {selected.metadata.input?.text && ( +

+ {selected.metadata.input.text} +

+ )} + {(selected.metadata.input ?? + selected.metadata.input_event) != null ? ( + + ) : ( +

+ {t('agents.monitoring.inputUnavailable')} +

+ )} +
+
+

+ {t('agents.monitoring.execution')} +

+ {steps.map((step, index) => ( + + + + {step.kind === 'tool' ? ( + <> + + + {labels[step.name] || step.name} + + + {t( + `agents.debugTool${step.status === 'running' ? (activeStatuses.has(selected.status) || eventCursor !== null ? 'Running' : 'Interrupted') : step.result && typeof step.result === 'object' && 'mock' in step.result && step.result.mock === true ? (step.status === 'failed' ? 'MockFailed' : 'Simulated') : step.status === 'failed' ? 'Failed' : 'Completed'}`, + )} + + + ) : ( + <> + + {t('agents.debugTextOutput')} + + )} + + + + {step.kind === 'message' ? ( + <> + {step.text && ( +

{step.text}

+ )} + {step.reasoning && ( + + )} + + ) : ( + <> + {step.error && ( +

+ {step.error} +

+ )} + + {step.result !== undefined && ( + + )} + + )} +
+
+ ))} + {events + .filter((event) => event.type === 'processor.log') + .map((event) => ( + + + {String(event.data.text ?? '')} + + + ))} + {eventCursor !== null && ( + + )} + + + + + +
+                      {json({
+                        run_id: selected.run_id,
+                        usage: selected.usage,
+                        events,
+                      })}
+                    
+
+
+ + ) + )} + {traceError && ( + + + {t('monitoring.loadError')} + + + + )} +
+
+
+ ); +} diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 758764e5a..eed1fed14 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -196,6 +196,13 @@ export interface RunnerDescriptor { export interface ProcessorRun { run_id: string; + binding_id?: string; + runner_id?: string; + usage?: { + prompt_tokens?: number | null; + completion_tokens?: number | null; + total_tokens?: number | null; + } | null; status: string; status_reason?: string; created_at: number; @@ -204,7 +211,13 @@ export interface ProcessorRun { created_at_ms?: number | null; started_at_ms?: number | null; finished_at_ms?: number | null; - metadata: { event_type?: string; input_event?: unknown; delivery?: unknown }; + metadata: { + event_type?: string; + source?: string; + input?: { text?: string; contents?: unknown[]; attachments?: unknown[] }; + input_event?: unknown; + delivery?: unknown; + }; } export interface ProcessorRunEvent { @@ -215,6 +228,7 @@ export interface ProcessorRunEvent { export interface ProcessorRunPage { items: ProcessorRun[]; + total?: number; next_cursor: number | null; has_more: boolean; } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 255f6aa57..cb3efcec0 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -787,6 +787,17 @@ const enUS = { }, }, agents: { + monitoring: { + description: + 'Follow each task from its triggering event through model output and tool execution.', + empty: + 'No runs yet. Trigger a platform event or run a debug test to see it here.', + input: 'Triggering input', + eventData: 'Event data', + execution: 'Execution steps', + rawEvents: 'Raw run events', + inputUnavailable: 'Input was not recorded for this run.', + }, eventProcessor: { configurations: 'Plugin processor configurations', configTab: 'Configuration', @@ -825,6 +836,7 @@ const enUS = { loadMore: 'Load more', activation: 'Install a plugin, create a processor configuration, then bind a bot.', + status_timeout: 'Timed out', status_pending: 'Pending', status_running: 'Running', status_completed: 'Completed', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 92e2c012c..859a40b46 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -611,6 +611,17 @@ const esES = { }, }, agents: { + monitoring: { + description: + 'Consulta el evento, la salida del modelo y las herramientas de cada tarea.', + empty: + 'Sin ejecuciones. Activa un evento o ejecuta una prueba de depuración.', + input: 'Entrada inicial', + eventData: 'Datos del evento', + execution: 'Pasos de ejecución', + rawEvents: 'Eventos sin procesar', + inputUnavailable: 'No se registró la entrada de esta ejecución.', + }, eventProcessor: { configurations: 'Configuraciones de procesadores de plugins', configTab: 'Configuración', @@ -649,6 +660,7 @@ const esES = { loadMore: 'Cargar más', activation: 'Instala un plugin, crea una configuración de procesador y vincula un bot.', + status_timeout: 'Tiempo agotado', status_pending: 'Pendiente', status_running: 'En ejecución', status_completed: 'Completado', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 15d5a80ff..8f74e59fa 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -800,6 +800,17 @@ const jaJP = { }, }, agents: { + monitoring: { + description: + '各タスクのトリガーイベント、モデル出力、ツール実行を確認します。', + empty: + '実行記録はありません。プラットフォームイベントまたはデバッグテストを実行してください。', + input: 'トリガー入力', + eventData: 'イベントデータ', + execution: '実行過程', + rawEvents: '生の実行イベント', + inputUnavailable: 'この実行の入力は記録されていません。', + }, eventProcessor: { configurations: 'プラグインプロセッサー設定', configTab: '設定', @@ -839,6 +850,7 @@ const jaJP = { loadMore: 'さらに読み込む', activation: 'プラグインをインストールし、プロセッサー設定を作成してボットに紐付けます。', + status_timeout: 'タイムアウト', status_pending: '待機中', status_running: '実行中', status_completed: '完了', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index eb56045b0..ac667b208 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -606,6 +606,17 @@ const ruRU = { }, }, agents: { + monitoring: { + description: + 'Просмотр события, ответа модели и вызовов инструментов для каждой задачи.', + empty: + 'Запусков пока нет. Вызовите событие платформы или запустите отладку.', + input: 'Входные данные', + eventData: 'Данные события', + execution: 'Ход выполнения', + rawEvents: 'Исходные события', + inputUnavailable: 'Входные данные этого запуска не записаны.', + }, eventProcessor: { configurations: 'Конфигурации обработчиков плагинов', configTab: 'Настройки', @@ -643,6 +654,7 @@ const ruRU = { loadMore: 'Загрузить ещё', activation: 'Установите плагин, создайте конфигурацию обработчика и привяжите бота.', + status_timeout: 'Время истекло', status_pending: 'Ожидание', status_running: 'Выполняется', status_completed: 'Завершено', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 4b4836fe2..abce3d8be 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -591,6 +591,16 @@ const thTH = { }, }, agents: { + monitoring: { + description: + 'ดูเหตุการณ์เริ่มต้น ผลลัพธ์โมเดล และการเรียกเครื่องมือของแต่ละงาน', + empty: 'ยังไม่มีการทำงาน เริ่มเหตุการณ์หรือทดสอบการดีบักเพื่อดูบันทึก', + input: 'ข้อมูลเริ่มต้น', + eventData: 'ข้อมูลเหตุการณ์', + execution: 'ขั้นตอนการทำงาน', + rawEvents: 'เหตุการณ์ดิบ', + inputUnavailable: 'ไม่มีการบันทึกข้อมูลเริ่มต้นของการทำงานนี้', + }, eventProcessor: { configurations: 'การตั้งค่าตัวประมวลผลปลั๊กอิน', configTab: 'การตั้งค่า', @@ -627,6 +637,7 @@ const thTH = { destination: 'ปลายทางการส่ง', loadMore: 'โหลดเพิ่มเติม', activation: 'ติดตั้งปลั๊กอิน สร้างการตั้งค่าตัวประมวลผล แล้วเชื่อมโยงบอท', + status_timeout: 'หมดเวลา', status_pending: 'รอดำเนินการ', status_running: 'กำลังทำงาน', status_completed: 'เสร็จสิ้น', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index d99ea2f8b..a89532b42 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -601,6 +601,16 @@ const viVN = { }, }, agents: { + monitoring: { + description: + 'Xem sự kiện kích hoạt, đầu ra mô hình và quá trình gọi công cụ của mỗi tác vụ.', + empty: 'Chưa có lượt chạy. Kích hoạt sự kiện hoặc chạy thử gỡ lỗi.', + input: 'Đầu vào kích hoạt', + eventData: 'Dữ liệu sự kiện', + execution: 'Quá trình thực thi', + rawEvents: 'Sự kiện gốc', + inputUnavailable: 'Đầu vào của lượt chạy này chưa được ghi lại.', + }, eventProcessor: { configurations: 'Cấu hình bộ xử lý plugin', configTab: 'Cấu hình', @@ -637,6 +647,7 @@ const viVN = { destination: 'Đích gửi', loadMore: 'Tải thêm', activation: 'Cài plugin, tạo cấu hình bộ xử lý rồi liên kết bot.', + status_timeout: 'Hết thời gian', status_pending: 'Đang chờ', status_running: 'Đang chạy', status_completed: 'Hoàn tất', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 0f0c6449c..929185719 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -748,6 +748,15 @@ const zhHans = { }, }, agents: { + monitoring: { + description: '查看每次任务的触发事件、模型输出和工具执行过程。', + empty: '暂无运行记录。触发平台事件或运行调试后,可在这里查看。', + input: '触发输入', + eventData: '事件数据', + execution: '执行过程', + rawEvents: '原始运行事件', + inputUnavailable: '这次运行未记录输入内容。', + }, eventProcessor: { configurations: '插件处理器配置', configTab: '配置', @@ -783,6 +792,7 @@ const zhHans = { destination: '投递目标', loadMore: '加载更多', activation: '安装插件,创建处理器配置,再绑定机器人。', + status_timeout: '已超时', status_pending: '待执行', status_running: '运行中', status_completed: '已完成', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 511cd5572..4e01695fc 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -573,6 +573,15 @@ const zhHant = { }, }, agents: { + monitoring: { + description: '查看每次任務的觸發事件、模型輸出和工具執行過程。', + empty: '尚無執行紀錄。觸發平台事件或執行除錯後,可在此查看。', + input: '觸發輸入', + eventData: '事件資料', + execution: '執行過程', + rawEvents: '原始執行事件', + inputUnavailable: '此次執行未記錄輸入內容。', + }, eventProcessor: { configurations: '外掛處理器設定', configTab: '設定', @@ -608,6 +617,7 @@ const zhHant = { destination: '傳送目標', loadMore: '載入更多', activation: '安裝外掛、建立處理器設定,再綁定機器人。', + status_timeout: '已逾時', status_pending: '待執行', status_running: '執行中', status_completed: '已完成', diff --git a/web/tests/e2e/agent-monitoring.spec.ts b/web/tests/e2e/agent-monitoring.spec.ts new file mode 100644 index 000000000..c3b8223d7 --- /dev/null +++ b/web/tests/e2e/agent-monitoring.spec.ts @@ -0,0 +1,231 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +const run = (id: string, status = 'completed') => ({ + run_id: id, + binding_id: `debug:agent-logs:plugin:qa/agent/default`, + runner_id: 'plugin:qa/agent/default', + status, + status_reason: status === 'failed' ? 'Model request timed out' : 'stop', + created_at: 1788000000, + started_at_ms: 1788000000000, + finished_at_ms: 1788000001500, + metadata: { + event_type: 'message.received', + source: 'webui', + input: { text: `Task ${id}` }, + }, + usage: { prompt_tokens: 30, completion_tokens: 12, total_tokens: 42 }, +}); + +test('Agent logs show task execution and keep the debug draft when switching tabs', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, + withRunnerToolSelector: true, + }); + let listCalls = 0; + await page.route('**/api/v1/agents/agent-logs/runs**', async (route) => { + const url = new URL(route.request().url()); + if (url.pathname.endsWith('/runs')) { + listCalls++; + return route.fulfill({ + json: { + code: 0, + data: { + items: [run('done'), run('failed', 'failed')], + total: 2, + has_more: false, + next_cursor: null, + }, + }, + }); + } + const failed = url.pathname.includes('/failed/'); + return route.fulfill({ + json: { + code: 0, + data: { + run: run(failed ? 'failed' : 'done', failed ? 'failed' : 'completed'), + items: failed + ? [] + : [ + { + sequence: 1, + type: 'message.delta', + data: { + chunk: { + role: 'assistant', + content: 'Checking the weather.', + model: 'test-model', + }, + }, + }, + { + sequence: 2, + type: 'tool.call.started', + data: { + tool_call_id: 'call-1', + tool_name: 'weather_lookup', + parameters: { city: 'Beijing' }, + }, + }, + { + sequence: 3, + type: 'tool.call.completed', + data: { + tool_call_id: 'call-1', + tool_name: 'weather_lookup', + result: { forecast: 'Sunny' }, + }, + }, + { + sequence: 4, + type: 'message.completed', + data: { + message: { + role: 'assistant', + content: 'It will be sunny.', + model: 'test-model', + }, + }, + }, + ], + has_more: false, + next_cursor: null, + }, + }, + }); + }); + await page.goto('/home/agents?id=agent-logs'); + const debug = page.getByRole('region', { name: 'Event Debug', exact: true }); + const draft = debug.getByRole('textbox', { name: 'Message content' }); + await draft.fill('Keep this draft'); + expect(listCalls).toBe(0); + await page.getByRole('tab', { name: 'Run logs', exact: true }).click(); + const log = page.getByTestId('agent-monitoring'); + await expect( + log.getByText('Task done', { exact: true }).last(), + ).toBeVisible(); + await expect(log.getByText('test-model', { exact: true })).toBeVisible(); + await expect(log.getByText('weather_lookup', { exact: true })).toBeVisible(); + await expect( + log.getByText('It will be sunny.', { exact: true }), + ).toBeVisible(); + await expect(log.getByText('Beijing', { exact: false })).not.toBeVisible(); + await log.getByRole('button', { name: 'Arguments', exact: true }).click(); + await expect(log.getByText(/"city": "Beijing"/)).toBeVisible(); + await log.getByRole('button').filter({ hasText: 'Task failed' }).click(); + await expect(log.getByText('Model request timed out')).toBeVisible(); + await expect(log.getByText('It will be sunny.', { exact: true })).toHaveCount( + 0, + ); + await page + .getByRole('tab', { name: 'Configure & debug', exact: true }) + .click(); + await expect(draft).toHaveValue('Keep this draft'); + await expect(page.getByTestId('agent-monitoring')).toHaveCount(0); +}); + +test('Agent log pagination retains selection and ignores a slow previous detail response', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + let release: () => void = () => {}; + const slow = new Promise((resolve) => { + release = resolve; + }); + let slowRequested = false; + await page.route('**/api/v1/agents/agent-logs/runs**', async (route) => { + const url = new URL(route.request().url()); + if (url.pathname.endsWith('/runs')) + return route.fulfill({ + json: { + code: 0, + data: { + items: url.searchParams.has('before_id') + ? [run('older')] + : [run('slow'), run('fast')], + total: 3, + has_more: !url.searchParams.has('before_id'), + next_cursor: url.searchParams.has('before_id') ? null : 2, + }, + }, + }); + const id = url.pathname.split('/').at(-2)!; + if (id === 'slow') { + slowRequested = true; + await slow; + } + const more = id === 'fast' && !url.searchParams.has('after_sequence'); + return route.fulfill({ + json: { + code: 0, + data: { + run: run(id), + items: [ + { + sequence: more ? 1 : 2, + type: 'message.completed', + data: { + message: { + role: 'assistant', + content: more ? 'First step' : `Result ${id}`, + }, + }, + }, + ], + has_more: more, + next_cursor: more ? 1 : null, + }, + }, + }); + }); + await page.goto('/home/agents?id=agent-logs'); + await page.getByRole('tab', { name: 'Run logs', exact: true }).click(); + await expect.poll(() => slowRequested).toBe(true); + const log = page.getByTestId('agent-monitoring'); + await log.getByRole('button').filter({ hasText: 'Task fast' }).click(); + await expect(log.getByText('First step', { exact: true })).toBeVisible(); + release(); + await log + .getByRole('region', { name: 'Execution steps' }) + .getByRole('button', { name: 'Load more' }) + .click(); + await expect(log.getByText('Result fast', { exact: true })).toBeVisible(); + await expect(log.getByText('Result slow', { exact: true })).toHaveCount(0); + await log.getByRole('button', { name: 'Load more', exact: true }).click(); + await expect( + log.getByRole('button').filter({ hasText: 'Task older' }), + ).toBeVisible(); + await expect( + log.getByRole('button').filter({ hasText: 'Task fast' }), + ).toHaveAttribute('aria-pressed', 'true'); +}); + +test('Agent logs show an empty state and retry request errors', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + let fail = true; + await page.route('**/api/v1/agents/agent-logs/runs**', (route) => + fail + ? route.fulfill({ status: 500, json: { code: -1, msg: 'Unavailable' } }) + : route.fulfill({ + json: { + code: 0, + data: { items: [], total: 0, has_more: false, next_cursor: null }, + }, + }), + ); + await page.goto('/home/agents?id=agent-logs'); + await page.getByRole('tab', { name: 'Run logs', exact: true }).click(); + const log = page.getByTestId('agent-monitoring'); + await expect(log.getByRole('alert')).toBeVisible(); + fail = false; + await log.getByRole('button', { name: 'Refresh', exact: false }).click(); + await expect(log.getByRole('alert')).toHaveCount(0); + await expect(log.getByText('No runs yet.', { exact: false })).toBeVisible(); +});