From 35e2fdf8fdd991dc5d2f06446325521442256cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9A=93=E9=99=88?= Date: Thu, 20 Aug 2026 16:14:47 +0800 Subject: [PATCH 1/5] feat: add experimental Monitor tools for event-driven watchers Add MonitorCreate/MonitorList/MonitorCancel tools to both engines so the agent can register one-shot listeners on background task output, shell commands, and file changes, and get interrupted when they trigger instead of polling. Gated behind the monitor experimental flag (KIMI_CODE_EXPERIMENTAL_MONITOR, default off). Also re-assert type:"object" at the root of union tool parameter schemas; model providers reject parameter schemas whose root lacks it. --- .changeset/monitor-watchers.md | 5 + .../src/tui/controllers/session-replay.ts | 30 + .../kimi-code/src/tui/utils/message-replay.ts | 18 + .../agent-core-v2/docs/state-manifest.d.ts | 34 +- .../agent-core-v2/docs/wire-manifest.d.ts | 112 +-- .../agent/contextMemory/compactionHandoff.ts | 1 + .../src/agent/contextMemory/types.ts | 13 + .../agent-core-v2/src/agent/monitor/errors.ts | 24 + .../agent-core-v2/src/agent/monitor/flag.ts | 16 + .../src/agent/monitor/monitor.ts | 149 +++ .../src/agent/monitor/monitorService.ts | 862 ++++++++++++++++++ packages/agent-core-v2/src/agent/task/task.ts | 9 + .../src/agent/task/taskService.ts | 9 + .../monitor/monitor-cancel/monitor-cancel.md | 3 + .../monitor/monitor-cancel/monitor-cancel.ts | 13 + .../monitor-cancel/monitorCancelTool.ts | 50 + .../monitor/monitor-create/monitor-create.md | 17 + .../monitor/monitor-create/monitor-create.ts | 71 ++ .../monitor-create/monitorCreateTool.ts | 117 +++ .../monitor/monitor-list/monitor-list.md | 3 + .../monitor/monitor-list/monitor-list.ts | 11 + .../monitor/monitor-list/monitorListTool.ts | 47 + .../agent-core-v2/src/app/telemetry/events.ts | 43 + packages/agent-core-v2/src/errors.ts | 3 + .../plan/injection/plan-mode-full-reminder.md | 2 +- .../src/features/plan/planService.ts | 11 + packages/agent-core-v2/src/index.ts | 9 + .../agentLifecycle/profile/profiles.ts | 3 + .../agent-core-v2/src/tool/input-schema.ts | 7 + .../fullCompaction/fullCompaction.test.ts | 14 +- .../test/agent/loop/loop.test.ts | 4 +- .../test/agent/monitor/monitorService.test.ts | 621 +++++++++++++ .../test/agent/task/tools/task-tools.test.ts | 4 + .../test/features/plan/plan.test.ts | 48 +- .../os/backends/node-local/tools/bash.test.ts | 2 + .../test/state/builtinReplayableKeys.ts | 2 + .../test/tool/input-schema.test.ts | 13 + packages/agent-core-v2/test/tool/tool.test.ts | 10 +- .../agent-core/src/agent/background/index.ts | 51 ++ .../src/agent/compaction/handoff.ts | 1 + .../agent-core/src/agent/context/index.ts | 3 + .../agent-core/src/agent/context/types.ts | 13 + packages/agent-core/src/agent/index.ts | 4 + .../src/agent/injection/plan-mode.ts | 2 +- .../agent-core/src/agent/monitor/index.ts | 2 + .../agent-core/src/agent/monitor/manager.ts | 749 +++++++++++++++ .../src/agent/monitor/monitor-fire.ts | 106 +++ .../policies/plan-mode-guard-deny.ts | 8 + packages/agent-core/src/agent/replay/turns.ts | 1 + packages/agent-core/src/agent/tool/index.ts | 9 + packages/agent-core/src/flags/registry.ts | 9 + .../agent-core/src/profile/default/agent.yaml | 3 + packages/agent-core/src/session/index.ts | 6 + .../src/session/store/session-store.ts | 2 + .../agent-core/src/tools/builtin/index.ts | 3 + .../src/tools/monitor/monitor-cancel.md | 5 + .../src/tools/monitor/monitor-cancel.ts | 66 ++ .../src/tools/monitor/monitor-create.md | 29 + .../src/tools/monitor/monitor-create.ts | 210 +++++ .../src/tools/monitor/monitor-list.md | 13 + .../src/tools/monitor/monitor-list.ts | 75 ++ .../src/tools/support/input-schema.ts | 13 + .../test/agent/compaction/handoff.test.ts | 10 + .../test/agent/monitor/monitor.test.ts | 683 ++++++++++++++ packages/agent-core/test/agent/plan.test.ts | 36 +- .../test/tools/input-schema-io.test.ts | 24 + .../kap-server/src/protocol/events-zod.ts | 10 + .../src/services/transcript/coreEventMap.ts | 8 + .../test/services/transcript.test.ts | 24 + packages/protocol/src/events.ts | 23 + packages/transcript/src/contract/schema.ts | 5 + packages/transcript/src/history/groupTurns.ts | 4 + packages/transcript/src/model/turn.ts | 1 + packages/transcript/test/layers.test.ts | 15 + 74 files changed, 4521 insertions(+), 115 deletions(-) create mode 100644 .changeset/monitor-watchers.md create mode 100644 packages/agent-core-v2/src/agent/monitor/errors.ts create mode 100644 packages/agent-core-v2/src/agent/monitor/flag.ts create mode 100644 packages/agent-core-v2/src/agent/monitor/monitor.ts create mode 100644 packages/agent-core-v2/src/agent/monitor/monitorService.ts create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitor-cancel.md create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitor-cancel.ts create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitorCancelTool.ts create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.md create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.ts create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitorCreateTool.ts create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitor-list.md create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitor-list.ts create mode 100644 packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitorListTool.ts create mode 100644 packages/agent-core-v2/test/agent/monitor/monitorService.test.ts create mode 100644 packages/agent-core/src/agent/monitor/index.ts create mode 100644 packages/agent-core/src/agent/monitor/manager.ts create mode 100644 packages/agent-core/src/agent/monitor/monitor-fire.ts create mode 100644 packages/agent-core/src/tools/monitor/monitor-cancel.md create mode 100644 packages/agent-core/src/tools/monitor/monitor-cancel.ts create mode 100644 packages/agent-core/src/tools/monitor/monitor-create.md create mode 100644 packages/agent-core/src/tools/monitor/monitor-create.ts create mode 100644 packages/agent-core/src/tools/monitor/monitor-list.md create mode 100644 packages/agent-core/src/tools/monitor/monitor-list.ts create mode 100644 packages/agent-core/test/agent/monitor/monitor.test.ts diff --git a/.changeset/monitor-watchers.md b/.changeset/monitor-watchers.md new file mode 100644 index 0000000000..66f8d2b184 --- /dev/null +++ b/.changeset/monitor-watchers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the MonitorCreate, MonitorList, and MonitorCancel tools so the agent can register one-shot listeners on background task output, shell commands, and file changes, and get notified the moment they trigger instead of polling. Enable with `KIMI_CODE_EXPERIMENTAL_MONITOR=1`. diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index db5589a31e..9d0b649d1c 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -36,6 +36,7 @@ import { formatHookResultMessageForTranscript, isTerminalBackgroundTask, limitReplayRecordsByTurn, + monitorOrigin, REPLAY_TURN_LIMIT, replayBackgroundProjection, replayEntry, @@ -331,6 +332,10 @@ export class SessionReplayRenderer { this.renderBackgroundTaskNotification(context, origin); return; } + if (monitorOrigin(message) !== undefined) { + this.renderMonitorNotification(context, message); + return; + } if (message.origin?.kind === 'hook_result') { this.renderHookResult(context, message); return; @@ -665,6 +670,19 @@ export class SessionReplayRenderer { }); } + private renderMonitorNotification(context: ReplayRenderContext, message: ContextMessage): void { + if (monitorOrigin(message) === undefined) return; + this.flushAssistant(context); + this.host.appendTranscriptEntry( + replayEntry( + context, + 'status', + stripMonitorNotificationEnvelope(contentPartsToText(message.content)), + 'plain', + ), + ); + } + private renderPermissionUpdate(context: ReplayRenderContext, mode: PermissionMode): void { if (mode === 'yolo') { this.host.appendTranscriptEntry( @@ -877,3 +895,15 @@ function stripCronEnvelope(text: string): string { } return text; } + +function stripMonitorNotificationEnvelope(text: string): string { + const lines = text.split('\n'); + if ( + lines.length >= 2 && + lines[0]?.startsWith('' + ) { + return lines.slice(1, -1).join('\n'); + } + return text; +} diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 1ef5c544c4..68355f3266 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -259,6 +259,24 @@ export function backgroundOrigin( return origin?.kind === 'background_task' || origin?.kind === 'task' ? origin : undefined; } +/** + * A fired Monitor watcher steers a user-role record whose content is the + * rendered `` XML. Read structurally: the + * SDK's origin union is typed from the v1 engine, which may lag the wire. + */ +export interface MonitorNotificationOrigin { + readonly kind: 'monitor'; + readonly monitorId: string; + readonly monitorType: 'task_output' | 'command' | 'file'; + readonly trigger: 'match' | 'exit' | 'timeout'; + readonly notificationId: string; +} + +export function monitorOrigin(message: ContextMessage): MonitorNotificationOrigin | undefined { + const origin = message.origin as MonitorNotificationOrigin | undefined; + return origin?.kind === 'monitor' ? origin : undefined; +} + export function skillActivationFromOrigin( origin: PromptOrigin | undefined, ): SkillActivationProjection | undefined { diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 3400b04eee..9169f80dd4 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 98 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 101 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -106,6 +106,9 @@ // mcp.mcpToolsByServer src/agent/mcp/mcpService.ts // media.registeredKey src/agent/media/mediaToolsRegistrar.ts // media.resolved src/agent/media/mediaResolverService.ts +// monitor.deliveredNotificationKeys src/agent/monitor/monitor.ts +// monitor.notificationDelivery src/agent/monitor/monitor.ts +// monitor.scheduledNotificationKeys src/agent/monitor/monitor.ts // permissionMode src/agent/permissionMode/permissionModeOps.ts // permissionMode.configured src/agent/permissionMode/permissionModeOps.ts // permissionMode.lastMode src/agent/permissionMode/injection/permissionModeInjection.ts @@ -824,6 +827,12 @@ export interface AgentStateSnapshot { readonly taskId: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; readonly notificationId: string; + } | /* MonitorOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'monitor'; + readonly monitorId: string; + readonly monitorType: /* MonitorOriginType — packages/agent-core-v2/src/agent/contextMemory/types.ts */ 'task_output' | 'command' | 'file'; + readonly trigger: /* MonitorOriginTrigger — packages/agent-core-v2/src/agent/contextMemory/types.ts */ 'match' | 'exit' | 'timeout'; + readonly notificationId: string; } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'cron_job'; readonly jobId: string; @@ -957,6 +966,12 @@ export interface AgentStateSnapshot { readonly taskId: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; readonly notificationId: string; + } | /* MonitorOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'monitor'; + readonly monitorId: string; + readonly monitorType: /* MonitorOriginType — packages/agent-core-v2/src/agent/contextMemory/types.ts */ 'task_output' | 'command' | 'file'; + readonly trigger: /* MonitorOriginTrigger — packages/agent-core-v2/src/agent/contextMemory/types.ts */ 'match' | 'exit' | 'timeout'; + readonly notificationId: string; } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'cron_job'; readonly jobId: string; @@ -1022,6 +1037,12 @@ export interface AgentStateSnapshot { readonly taskId: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; readonly notificationId: string; + } | /* MonitorOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'monitor'; + readonly monitorId: string; + readonly monitorType: /* MonitorOriginType — packages/agent-core-v2/src/agent/contextMemory/types.ts */ 'task_output' | 'command' | 'file'; + readonly trigger: /* MonitorOriginTrigger — packages/agent-core-v2/src/agent/contextMemory/types.ts */ 'match' | 'exit' | 'timeout'; + readonly notificationId: string; } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'cron_job'; readonly jobId: string; @@ -1165,6 +1186,12 @@ export interface AgentStateSnapshot { readonly taskId: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; readonly notificationId: string; + } | /* MonitorOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'monitor'; + readonly monitorId: string; + readonly monitorType: /* MonitorOriginType — packages/agent-core-v2/src/agent/contextMemory/types.ts */ 'task_output' | 'command' | 'file'; + readonly trigger: /* MonitorOriginTrigger — packages/agent-core-v2/src/agent/contextMemory/types.ts */ 'match' | 'exit' | 'timeout'; + readonly notificationId: string; } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'cron_job'; readonly jobId: string; @@ -1341,6 +1368,11 @@ export interface AgentStateSnapshot { }>; // src/agent/media/mediaToolsRegistrar.ts 'media.registeredKey': string | undefined; + // src/agent/monitor/monitor.ts + 'monitor.deliveredNotificationKeys': Set; + // replayable · durable · undoable — folds: ContextAppendMessage + 'monitor.notificationDelivery': readonly string[]; + 'monitor.scheduledNotificationKeys': Set; // src/agent/permissionMode/injection/permissionModeInjection.ts 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; // src/agent/permissionMode/permissionModeOps.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 7c944d95ee..1346b7ea62 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -25,55 +25,55 @@ // media to blob storage), owner (the source file declaring the class). // Index (49 record types) -// config.update profile src/agent/profile/profileOps.ts -// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts -// context.append_message contextMemory, goalForkNotice, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts -// context.apply_compaction contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts -// context.clear contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts -// context.undo contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts -// forked goal, goalForkNotice src/agent/goal/goalOps.ts -// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts -// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts -// full_compaction.complete fullCompaction src/agent/fullCompaction/compactionOps.ts -// goal.clear goal, goalForkNotice src/agent/goal/goalOps.ts -// goal.create goal, goalForkNotice src/agent/goal/goalOps.ts -// goal.update goal src/agent/goal/goalOps.ts -// interaction.request interaction src/session/interaction/interactionOps.ts -// interaction.resolved interaction src/session/interaction/interactionOps.ts -// interruptionReminder.recorded interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts -// llm.request llm.requestTrace src/agent/llmRequester/llmRequestOps.ts -// llm.tools_snapshot llm.requestTrace src/agent/llmRequester/llmRequestOps.ts -// mcp.tools_discovered mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts -// permission.record_approval_result permissionRules src/agent/permissionRules/permissionRulesOps.ts -// permission.set_mode permissionMode, permissionMode.configured src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan src/features/plan/planOps.ts -// plan_mode.enter plan src/features/plan/planOps.ts -// plan_mode.exit plan src/features/plan/planOps.ts -// plan.revision plan src/features/plan/planOps.ts -// plugin.session_start pluginSessionStartSnapshot src/agent/plugin/agentPluginOps.ts -// profile.bind profile, profile.activeTools src/agent/profile/profileOps.ts -// prompt.accepted promptAdmission src/agent/prompt/promptOps.ts -// runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts -// swarm_mode.enter swarm src/features/swarm/swarmOps.ts -// swarm_mode.exit contextMemory, swarm src/features/swarm/swarmOps.ts -// task.started task src/agent/task/taskOps.ts -// task.terminated task src/agent/task/taskOps.ts -// task.waitDelivered task.notificationDelivery src/agent/task/taskOps.ts -// token_counting.measured tokenCounting src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.rebased tokenCounting src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.truncated tokenCounting src/agent/tokenCounting/tokenCountingOps.ts -// tools.register_user_tool userTool src/agent/userTool/userToolOps.ts -// tools.reset_active_tools profile.activeTools src/agent/profile/profileOps.ts -// tools.set_active_tools profile.activeTools src/agent/profile/profileOps.ts -// tools.unregister_user_tool userTool src/agent/userTool/userToolOps.ts -// tools.update_store todo src/session/todo/todoOps.ts -// tower_mode.enter tower src/features/tower/towerOps.ts -// tower_mode.exit tower src/features/tower/towerOps.ts -// turn.cancel turn src/agent/loop/turnOps.ts -// turn.ended turn src/agent/loop/turnOps.ts -// turn.prompt turn src/agent/loop/turnOps.ts -// turn.steer turn src/agent/loop/turnOps.ts -// usage.record usage src/agent/usage/usageOps.ts +// config.update profile src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts +// context.append_message contextMemory, goalForkNotice, monitor.notificationDelivery, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts +// context.apply_compaction contextMemory, monitor.notificationDelivery, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts +// context.clear contextMemory, monitor.notificationDelivery, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts +// context.undo contextMemory, monitor.notificationDelivery, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts +// forked goal, goalForkNotice src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction src/agent/fullCompaction/compactionOps.ts +// goal.clear goal, goalForkNotice src/agent/goal/goalOps.ts +// goal.create goal, goalForkNotice src/agent/goal/goalOps.ts +// goal.update goal src/agent/goal/goalOps.ts +// interaction.request interaction src/session/interaction/interactionOps.ts +// interaction.resolved interaction src/session/interaction/interactionOps.ts +// interruptionReminder.recorded interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.request llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode, permissionMode.configured src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan src/features/plan/planOps.ts +// plan_mode.enter plan src/features/plan/planOps.ts +// plan_mode.exit plan src/features/plan/planOps.ts +// plan.revision plan src/features/plan/planOps.ts +// plugin.session_start pluginSessionStartSnapshot src/agent/plugin/agentPluginOps.ts +// profile.bind profile, profile.activeTools src/agent/profile/profileOps.ts +// prompt.accepted promptAdmission src/agent/prompt/promptOps.ts +// runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts +// swarm_mode.enter swarm src/features/swarm/swarmOps.ts +// swarm_mode.exit contextMemory, swarm src/features/swarm/swarmOps.ts +// task.started task src/agent/task/taskOps.ts +// task.terminated task src/agent/task/taskOps.ts +// task.waitDelivered task.notificationDelivery src/agent/task/taskOps.ts +// token_counting.measured tokenCounting src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased tokenCounting src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated tokenCounting src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool src/agent/userTool/userToolOps.ts +// tools.update_store todo src/session/todo/todoOps.ts +// tower_mode.enter tower src/features/tower/towerOps.ts +// tower_mode.exit tower src/features/tower/towerOps.ts +// turn.cancel turn src/agent/loop/turnOps.ts +// turn.ended turn src/agent/loop/turnOps.ts +// turn.prompt turn src/agent/loop/turnOps.ts +// turn.steer turn src/agent/loop/turnOps.ts +// usage.record usage src/agent/usage/usageOps.ts /** * states: profile @@ -109,7 +109,7 @@ interface ContextAppendLoopEventPayload { } /** - * states: contextMemory, goalForkNotice, plan, task.notificationDelivery, todo · blobs: contextMemory + * states: contextMemory, goalForkNotice, monitor.notificationDelivery, plan, task.notificationDelivery, todo · blobs: contextMemory * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextAppendMessagePayload { @@ -137,21 +137,21 @@ interface ContextAppendMessagePayload { }[]; id?: string; providerMessageId?: string; - origin?: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry' | undefined; + origin?: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'monitor' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry' | undefined; isError?: boolean; note?: string; }; } /** - * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * states: contextMemory, monitor.notificationDelivery, plan, task.notificationDelivery, todo · blobs: contextMemory * owner: src/agent/contextMemory/contextEvents.ts * shared base: ...contextCompactionBaseShape */ type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number }); /** - * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * states: contextMemory, monitor.notificationDelivery, plan, task.notificationDelivery, todo · blobs: contextMemory * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextClearPayload { @@ -159,7 +159,7 @@ interface ContextClearPayload { } /** - * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * states: contextMemory, monitor.notificationDelivery, plan, task.notificationDelivery, todo · blobs: contextMemory * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextUndoPayload { @@ -675,7 +675,7 @@ interface TurnPromptPayload { _name: 'turn.prompt'; input: readonly ContentPart[]; /** PromptOrigin */ - origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; + origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'monitor' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; } /** @@ -686,7 +686,7 @@ interface TurnSteerPayload { _name: 'turn.steer'; input: readonly ContentPart[]; /** PromptOrigin */ - origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; + origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'monitor' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; } /** diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index fd05dda5ad..970099b0ab 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -175,6 +175,7 @@ export function compactionUserMessageDisposition( case 'compaction_summary': case 'system_trigger': case 'task': + case 'monitor': case 'cron_job': case 'cron_missed': case 'hook_result': diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 6907ddc189..3e6a561fca 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -69,6 +69,18 @@ export interface TaskOrigin { readonly notificationId: string; } +export type MonitorOriginType = 'task_output' | 'command' | 'file'; + +export type MonitorOriginTrigger = 'match' | 'exit' | 'timeout'; + +export interface MonitorOrigin { + readonly kind: 'monitor'; + readonly monitorId: string; + readonly monitorType: MonitorOriginType; + readonly trigger: MonitorOriginTrigger; + readonly notificationId: string; +} + export interface CronJobOrigin { readonly kind: 'cron_job'; readonly jobId: string; @@ -103,6 +115,7 @@ export type PromptOrigin = | CompactionSummaryOrigin | SystemTriggerOrigin | TaskOrigin + | MonitorOrigin | CronJobOrigin | CronMissedOrigin | HookResultOrigin diff --git a/packages/agent-core-v2/src/agent/monitor/errors.ts b/packages/agent-core-v2/src/agent/monitor/errors.ts new file mode 100644 index 0000000000..89e045ceb8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/monitor/errors.ts @@ -0,0 +1,24 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; +import { Error2, type Error2Options } from '#/_base/errors/errors'; + +export const MonitorErrors = { + codes: { + MONITOR_LIMIT_EXCEEDED: 'monitor.limit_exceeded', + MONITOR_NOT_FOUND: 'monitor.not_found', + MONITOR_INVALID_PATTERN: 'monitor.invalid_pattern', + MONITOR_WATCH_FAILED: 'monitor.watch_failed', + MONITOR_RUNTIME_UNAVAILABLE: 'monitor.runtime_unavailable', + }, + retryable: ['monitor.limit_exceeded'], +} as const satisfies ErrorDomain; + +registerErrorDomain(MonitorErrors); + +export type MonitorErrorCode = (typeof MonitorErrors.codes)[keyof typeof MonitorErrors.codes]; + +export class MonitorError extends Error2 { + constructor(code: MonitorErrorCode, message: string, options?: Error2Options) { + super(code, message, options); + this.name = 'MonitorError'; + } +} diff --git a/packages/agent-core-v2/src/agent/monitor/flag.ts b/packages/agent-core-v2/src/agent/monitor/flag.ts new file mode 100644 index 0000000000..cefed5c67b --- /dev/null +++ b/packages/agent-core-v2/src/agent/monitor/flag.ts @@ -0,0 +1,16 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const MONITOR_FLAG_ID = 'monitor'; +export const MONITOR_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MONITOR'; + +export const monitorFlag: FlagDefinitionInput = { + id: MONITOR_FLAG_ID, + title: 'Monitor (event-driven watchers)', + description: + 'Let the agent register one-shot listeners on background task output, arbitrary shell commands, and file changes; a match pushes a notification back into the main loop instead of polling.', + env: MONITOR_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(monitorFlag); diff --git a/packages/agent-core-v2/src/agent/monitor/monitor.ts b/packages/agent-core-v2/src/agent/monitor/monitor.ts new file mode 100644 index 0000000000..0d0e319e30 --- /dev/null +++ b/packages/agent-core-v2/src/agent/monitor/monitor.ts @@ -0,0 +1,149 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Event2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; +import { ContextAppendMessage } from '#/agent/contextMemory/contextEvents'; +import type { MonitorOrigin } from '#/agent/contextMemory/types'; + +export type MonitorType = 'task_output' | 'command' | 'file'; + +export type MonitorTrigger = 'match' | 'exit' | 'timeout'; + +export type MonitorFileEvent = 'created' | 'modified'; + +export type MonitorStatus = 'active' | 'fired' | 'cancelled' | 'ended' | 'lost'; + +export const MONITOR_MAX_ACTIVE = 20; + +export interface MonitorSpecBase { + readonly timeoutMs: number; + readonly description?: string; +} + +export interface TaskOutputMonitorSpec extends MonitorSpecBase { + readonly type: 'task_output'; + readonly taskId: string; + readonly pattern: string; +} + +export interface CommandMonitorSpec extends MonitorSpecBase { + readonly type: 'command'; + readonly command: string; + readonly pattern?: string; +} + +export interface FileMonitorSpec extends MonitorSpecBase { + readonly type: 'file'; + readonly path: string; + readonly events?: readonly MonitorFileEvent[]; +} + +export type MonitorSpec = TaskOutputMonitorSpec | CommandMonitorSpec | FileMonitorSpec; + +export interface MonitorInfo { + readonly monitorId: string; + readonly type: MonitorType; + readonly status: MonitorStatus; + readonly description?: string; + readonly timeoutMs: number; + readonly createdAt: number; + readonly endedAt: number | null; + readonly trigger?: MonitorTrigger; + readonly taskId?: string; + readonly pattern?: string; + readonly command?: string; + readonly path?: string; + readonly events?: readonly MonitorFileEvent[]; +} + +export type MonitorNotification = Record & { + readonly id: string; + readonly category: 'monitor'; + readonly type: string; + readonly source_kind: 'monitor'; + readonly source_id: string; + readonly title: string; + readonly severity: 'info' | 'warning'; + readonly body: string; +}; + +export interface MonitorFiredNotification { + readonly origin: MonitorOrigin; + readonly notification: MonitorNotification; +} + +export interface MonitorNotificationContext { + readonly notificationType: string; + readonly title: string; + readonly body: string; + readonly severity: 'info' | 'warning'; + readonly sourceKind: string; + readonly sourceId: string; +} + +export class MonitorNotified extends Event2 { + static override readonly type = 'monitor.notified'; + static override readonly observable = true; +} +export interface MonitorNotified extends MonitorNotificationContext {} + +export interface IAgentMonitorService { + readonly _serviceBrand: undefined; + + createMonitor(spec: MonitorSpec): Promise; + listMonitors(): readonly MonitorInfo[]; + cancelMonitor(monitorId: string): Promise; +} + +export const IAgentMonitorService: ServiceIdentifier = + createDecorator('agentMonitorService'); + +export function isMonitorOrigin(origin: unknown): origin is MonitorOrigin { + if (typeof origin !== 'object' || origin === null) return false; + const value = origin as Record; + return ( + value['kind'] === 'monitor' && + typeof value['monitorId'] === 'string' && + typeof value['monitorType'] === 'string' && + typeof value['trigger'] === 'string' && + typeof value['notificationId'] === 'string' + ); +} + +export function monitorNotificationKey( + origin: Pick, +): string { + return `${origin.monitorId}\0${origin.trigger}\0${origin.notificationId}`; +} + +function monitorOriginFromMessage(message: unknown): MonitorOrigin | undefined { + if (typeof message !== 'object' || message === null) return undefined; + const origin = (message as { readonly origin?: unknown }).origin; + return isMonitorOrigin(origin) ? origin : undefined; +} + +export const monitorNotificationDeliveryKey = defineState( + 'monitor.notificationDelivery', + (): readonly string[] => [], +) + .replayable({ schema: z.custom() }) + .undoable() + .on(ContextAppendMessage, (s, e) => { + const origin = monitorOriginFromMessage(e.message); + if (origin === undefined) return; + const key = monitorNotificationKey(origin); + if (!s.includes(key)) { + s.push(key); + } + }); + +export const monitorScheduledNotificationKeysKey = defineState>( + 'monitor.scheduledNotificationKeys', + () => new Set(), +); +export const monitorDeliveredNotificationKeysKey = defineState>( + 'monitor.deliveredNotificationKeys', + () => new Set(), +); diff --git a/packages/agent-core-v2/src/agent/monitor/monitorService.ts b/packages/agent-core-v2/src/agent/monitor/monitorService.ts new file mode 100644 index 0000000000..12a27d42f9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/monitor/monitorService.ts @@ -0,0 +1,862 @@ +import { randomBytes } from 'node:crypto'; + +import picomatch from 'picomatch'; +import { resolve } from 'pathe'; + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { ILogService } from '#/_base/log/log'; +import { setClampedTimeout } from '#/_base/utils/timer'; +import { IEventBus } from '#/app/event/eventBus'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; +import type { ContextMessage, MonitorOrigin } from '#/agent/contextMemory/types'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { MessageStepRequest } from '#/agent/loop/stepRequest'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentTaskService, type AgentTaskInfo, type AgentTaskOutputChunk } from '#/agent/task/task'; +import { TERMINAL_STATUSES } from '#/agent/task/types'; +import { renderNotificationXml } from '#/agent/task/notificationXml'; +import { TaskTerminatedNotice } from '#/agent/task/taskOps'; +import { ProcessTask } from '#/agent/tools/os/bash/process-task'; +import type { IHostProcess } from '#/os/interface/hostProcess'; +import type { RuntimeLease } from '#/runtime/runtime'; +import { + type IHostFsWatchHandle, + IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; +import { + IAgentMonitorService, + MONITOR_MAX_ACTIVE, + MonitorNotified, + isMonitorOrigin, + monitorDeliveredNotificationKeysKey, + monitorNotificationDeliveryKey, + monitorNotificationKey, + monitorScheduledNotificationKeysKey, + type CommandMonitorSpec, + type FileMonitorSpec, + type MonitorFileEvent, + type MonitorFiredNotification, + type MonitorInfo, + type MonitorNotification, + type MonitorSpec, + type MonitorStatus, + type MonitorTrigger, + type MonitorType, + type TaskOutputMonitorSpec, +} from './monitor'; +import { MonitorError, MonitorErrors } from './errors'; + +const MONITOR_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; +const LINE_REMAINDER_CAP_CHARS = 4 * 1024; +const MATCHED_LINE_MAX_CHARS = 500; +const MONITOR_DOC_SUFFIX = '.json'; +const GLOB_MAGIC = /[*?{}[\]]/; + +interface MonitorRecord { + monitorId: string; + type: MonitorType; + status: MonitorStatus; + description?: string; + timeoutMs: number; + createdAt: number; + endedAt: number | null; + trigger?: MonitorTrigger; + notificationId?: string; + taskId?: string; + pattern?: string; + command?: string; + path?: string; + events?: readonly MonitorFileEvent[]; + fired?: MonitorFiredNotification; +} + +interface ManagedMonitor { + readonly record: MonitorRecord; + regex?: RegExp; + lineRemainder: string; + watchedTaskId?: string; + timeoutHandle?: ReturnType; + watchHandle?: IHostFsWatchHandle; + watchSubscription?: IDisposable; + matchesFile?: (changedPath: string) => boolean; +} + +interface MonitorFireDetail { + readonly matchedLine?: string; + readonly exitCode?: number | null; + readonly changedPath?: string; +} + +export class MonitorNotificationStepRequest extends MessageStepRequest { + constructor( + message: ContextMessage, + private readonly onWillDeliver?: () => void, + ) { + super(message, { + kind: 'monitor_notification', + mergeable: true, + turnScoped: false, + admission: 'activeOrNewTurn', + }); + } + + override onWillMaterialize(): void { + this.onWillDeliver?.(); + } +} + +export class AgentMonitorService extends Disposable implements IAgentMonitorService { + declare readonly _serviceBrand: undefined; + + private readonly monitors = new Map(); + private readonly outputWatchers = new Map>(); + private readonly terminalWatchers = new Map>(); + private readonly pendingNotificationRequests = new Map(); + private restoreQueue: Promise = Promise.resolve(); + + constructor( + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentLoopService private readonly loop: IAgentLoopService, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IEventBus private readonly eventBus: IEventBus, + @IAgentStateService private readonly states: IAgentStateService, + @IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore, + @ISessionContext private readonly session: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentConversationUndoParticipantRegistry + undoParticipants: IAgentConversationUndoParticipantRegistry, + @ILogService private readonly log: ILogService, + ) { + super(); + this.states.contributeState(monitorNotificationDeliveryKey); + this.states.contributeState(monitorScheduledNotificationKeysKey); + this.states.contributeState(monitorDeliveredNotificationKeysKey); + this._register( + this.tasks.onDidAppendOutput((chunk) => { + this.forwardOutput(chunk); + }), + ); + this._register( + this.eventBus.subscribe(TaskTerminatedNotice, (e) => { + this.onTaskTerminated(e.info); + }), + ); + this._register( + undoParticipants.register({ + id: 'monitor.notificationDelivery', + reconcileAfterUndo: () => this.reconcileNotificationDeliveryAfterUndo(), + }), + ); + this._register( + this.dispatcher.hooks.onDidRestore.register('monitor', async (_ctx, next) => { + for (const key of this.states.get(monitorNotificationDeliveryKey)) { + this.deliveredNotificationKeys.add(key); + } + await this.restoreAfterReplay(); + await next(); + }), + ); + this._register( + this.eventBus.subscribe(ContextSpliced, (e) => { + for (const message of e.messages) { + if (isMonitorOrigin(message.origin)) { + this.markDeliveredNotification(message.origin); + } + } + }), + ); + void this.restoreAfterReplay(); + } + + private get scheduledNotificationKeys(): Set { + return this.states.get(monitorScheduledNotificationKeysKey); + } + + private get deliveredNotificationKeys(): Set { + return this.states.get(monitorDeliveredNotificationKeysKey); + } + + async createMonitor(spec: MonitorSpec): Promise { + if (this.activeCount() >= MONITOR_MAX_ACTIVE) { + throw new MonitorError( + MonitorErrors.codes.MONITOR_LIMIT_EXCEEDED, + `Too many active monitors (max ${String(MONITOR_MAX_ACTIVE)}). Cancel one before creating another.`, + ); + } + const record: MonitorRecord = { + monitorId: generateMonitorId(), + type: spec.type, + status: 'active', + description: spec.description, + timeoutMs: spec.timeoutMs, + createdAt: Date.now(), + endedAt: null, + }; + const managed: ManagedMonitor = { record, lineRemainder: '' }; + switch (spec.type) { + case 'task_output': + this.setupTaskOutputMonitor(managed, spec); + break; + case 'command': + await this.setupCommandMonitor(managed, spec); + break; + case 'file': + await this.setupFileMonitor(managed, spec); + break; + } + this.monitors.set(record.monitorId, managed); + if (record.status === 'active') { + this.armTimeout(managed); + } + await this.persistRecord(record); + this.telemetry.track2('monitor_created', { + monitor_type: record.type, + has_pattern: record.pattern !== undefined, + timeout_ms: record.timeoutMs, + }); + return this.toInfo(record); + } + + listMonitors(): readonly MonitorInfo[] { + return [...this.monitors.values()] + .map((managed) => this.toInfo(managed.record)) + .toSorted((a, b) => a.createdAt - b.createdAt); + } + + async cancelMonitor(monitorId: string): Promise { + const managed = this.monitors.get(monitorId); + if (managed === undefined) return undefined; + const record = managed.record; + if (record.status !== 'active') return this.toInfo(record); + record.status = 'cancelled'; + record.endedAt = Date.now(); + this.cleanupMonitor(managed); + if (record.type === 'command' && record.taskId !== undefined) { + void this.tasks.stop(record.taskId, `Monitor ${monitorId} cancelled`).catch(() => {}); + } + await this.persistRecord(record); + this.telemetry.track2('monitor_cancelled', { + monitor_type: record.type, + duration_ms: record.endedAt - record.createdAt, + }); + return this.toInfo(record); + } + + override dispose(): void { + for (const managed of this.monitors.values()) { + this.cleanupMonitor(managed); + } + super.dispose(); + } + + private activeCount(): number { + let count = 0; + for (const managed of this.monitors.values()) { + if (managed.record.status === 'active') count++; + } + return count; + } + + private setupTaskOutputMonitor(managed: ManagedMonitor, spec: TaskOutputMonitorSpec): void { + const record = managed.record; + managed.regex = this.compilePattern(spec.pattern); + record.pattern = spec.pattern; + record.taskId = spec.taskId; + const target = this.tasks.getTask(spec.taskId); + if (target === undefined) { + throw new MonitorError( + MonitorErrors.codes.MONITOR_NOT_FOUND, + `Task not found: ${spec.taskId}`, + ); + } + if (TERMINAL_STATUSES.has(target.status)) { + record.status = 'ended'; + record.endedAt = Date.now(); + return; + } + this.addWatcher(this.outputWatchers, spec.taskId, managed); + this.addWatcher(this.terminalWatchers, spec.taskId, managed); + } + + private async setupCommandMonitor(managed: ManagedMonitor, spec: CommandMonitorSpec): Promise { + const record = managed.record; + if (spec.pattern !== undefined) { + managed.regex = this.compilePattern(spec.pattern); + record.pattern = spec.pattern; + } + record.command = spec.command; + const lease = this.runtime.acquire(['process']); + if (lease.runtime.process === undefined) { + lease.dispose(); + throw new MonitorError( + MonitorErrors.codes.MONITOR_RUNTIME_UNAVAILABLE, + 'The active runtime cannot spawn processes.', + ); + } + let proc: IHostProcess; + try { + proc = lease.track(await this.spawnMonitorCommand(lease, spec.command)); + } catch (error) { + lease.dispose(); + throw error; + } + const description = + spec.description !== undefined && spec.description.length > 0 + ? spec.description + : `Monitor: ${spec.command}`; + let taskId: string; + try { + taskId = this.tasks.registerTask( + new ProcessTask(proc, spec.command, description, undefined, () => { + lease.dispose(); + }), + { terminalNotificationSuppressed: true }, + ); + } catch (error) { + try { + await proc.kill('SIGTERM'); + } catch { + } finally { + await disposeQuietly(proc); + lease.dispose(); + } + throw error; + } + record.taskId = taskId; + if (managed.regex !== undefined) { + this.addWatcher(this.outputWatchers, taskId, managed); + } + this.addWatcher(this.terminalWatchers, taskId, managed); + } + + private async setupFileMonitor(managed: ManagedMonitor, spec: FileMonitorSpec): Promise { + const record = managed.record; + const absolute = resolve(this.session.cwd, spec.path); + record.path = spec.path; + record.events = spec.events ?? ['created', 'modified']; + const actions = new Set(record.events); + const isGlob = GLOB_MAGIC.test(spec.path); + const matcher = isGlob ? picomatch(normalizeSlashes(absolute)) : undefined; + managed.matchesFile = (changedPath: string): boolean => { + const normalized = normalizeSlashes(changedPath); + if (matcher !== undefined) return matcher(normalized); + const target = normalizeSlashes(absolute); + return normalized === target || normalized.startsWith(`${target}/`); + }; + const watchRoot = isGlob ? staticWatchRoot(absolute) : absolute; + const handle = this.fsWatch.watch(watchRoot, { recursive: true }); + managed.watchHandle = handle; + managed.watchSubscription = handle.onDidChange((change) => { + if (change.action !== 'created' && change.action !== 'modified') return; + if (!actions.has(change.action)) return; + if (managed.matchesFile?.(change.path) !== true) return; + this.fireMonitor(managed, 'match', { changedPath: change.path }); + }); + try { + await handle.ready; + } catch (error) { + managed.watchSubscription?.dispose(); + managed.watchSubscription = undefined; + managed.watchHandle = undefined; + handle.dispose(); + throw new MonitorError( + MonitorErrors.codes.MONITOR_WATCH_FAILED, + `Failed to watch ${watchRoot}: ${errorMessage(error)}`, + ); + } + } + + private spawnMonitorCommand(lease: RuntimeLease, command: string): Promise { + const processService = lease.runtime.process; + if (processService === undefined) { + throw new MonitorError( + MonitorErrors.codes.MONITOR_RUNTIME_UNAVAILABLE, + 'The active runtime cannot spawn processes.', + ); + } + const env = lease.runtime.environment; + const cwd = env.osKind === 'Windows' ? windowsPathToPosixPath(this.session.cwd) : this.session.cwd; + const shellCommand = `cd ${shellQuote(cwd)} && ${command}`; + const noninteractiveEnv: Record = { + NO_COLOR: '1', + TERM: 'dumb', + GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0', + SHELL: env.shellPath, + }; + return processService.spawn(env.shellPath, ['-c', shellCommand], { env: noninteractiveEnv }); + } + + private compilePattern(pattern: string): RegExp { + try { + return new RegExp(pattern); + } catch (error) { + throw new MonitorError( + MonitorErrors.codes.MONITOR_INVALID_PATTERN, + `Invalid regular expression: ${errorMessage(error)}`, + ); + } + } + + private addWatcher( + registry: Map>, + taskId: string, + managed: ManagedMonitor, + ): void { + let set = registry.get(taskId); + if (set === undefined) { + set = new Set(); + registry.set(taskId, set); + } + set.add(managed); + managed.watchedTaskId = taskId; + } + + private removeWatcher( + registry: Map>, + managed: ManagedMonitor, + ): void { + const taskId = managed.watchedTaskId; + if (taskId === undefined) return; + const set = registry.get(taskId); + if (set !== undefined) { + set.delete(managed); + if (set.size === 0) registry.delete(taskId); + } + } + + private armTimeout(managed: ManagedMonitor): void { + managed.timeoutHandle = setClampedTimeout(() => { + managed.timeoutHandle = undefined; + this.fireMonitor(managed, 'timeout', {}); + }, managed.record.timeoutMs); + managed.timeoutHandle.unref?.(); + } + + private forwardOutput(chunk: AgentTaskOutputChunk): void { + const watchers = this.outputWatchers.get(chunk.taskId); + if (watchers === undefined) return; + for (const managed of watchers) { + this.feedChunk(managed, chunk.chunk); + } + } + + private feedChunk(managed: ManagedMonitor, chunk: string): void { + const regex = managed.regex; + if (regex === undefined || managed.record.status !== 'active') return; + const text = managed.lineRemainder + chunk; + const lines = text.split('\n'); + managed.lineRemainder = lines.pop() ?? ''; + if (managed.lineRemainder.length > LINE_REMAINDER_CAP_CHARS) { + managed.lineRemainder = managed.lineRemainder.slice(-LINE_REMAINDER_CAP_CHARS); + } + for (const line of lines) { + regex.lastIndex = 0; + if (regex.test(line)) { + this.fireMonitor(managed, 'match', { + matchedLine: + line.length > MATCHED_LINE_MAX_CHARS + ? line.slice(0, MATCHED_LINE_MAX_CHARS) + : line, + }); + return; + } + } + } + + private onTaskTerminated(info: AgentTaskInfo): void { + const watchers = this.terminalWatchers.get(info.taskId); + if (watchers === undefined) return; + for (const managed of watchers) { + if (managed.record.status !== 'active') continue; + if (managed.record.type === 'command') { + this.fireMonitor(managed, 'exit', { + exitCode: info.kind === 'process' ? info.exitCode : null, + }); + } else if (managed.record.type === 'task_output') { + this.endMonitor(managed); + } + } + } + + private fireMonitor( + managed: ManagedMonitor, + trigger: MonitorTrigger, + detail: MonitorFireDetail, + ): void { + const record = managed.record; + if (record.status !== 'active') return; + record.status = 'fired'; + record.trigger = trigger; + record.endedAt = Date.now(); + record.notificationId = `monitor:${record.monitorId}:${trigger}`; + this.cleanupMonitor(managed); + const origin: MonitorOrigin = { + kind: 'monitor', + monitorId: record.monitorId, + monitorType: record.type, + trigger, + notificationId: record.notificationId, + }; + const notification: MonitorNotification = { + id: record.notificationId, + category: 'monitor', + type: `monitor.${record.type}.${trigger}`, + source_kind: 'monitor', + source_id: record.monitorId, + title: monitorNotificationTitle(record, trigger), + severity: monitorNotificationSeverity(trigger, detail), + body: buildMonitorNotificationBody(record, trigger, detail), + }; + record.fired = { origin, notification }; + this.scheduledNotificationKeys.add(monitorNotificationKey(origin)); + void this.persistRecord(record); + this.telemetry.track2('monitor_fired', { + monitor_type: record.type, + trigger, + duration_ms: record.endedAt - record.createdAt, + }); + if (record.type === 'command' && trigger !== 'exit' && record.taskId !== undefined) { + void this.tasks + .stop(record.taskId, `Monitor ${record.monitorId} ${trigger}`) + .catch(() => {}); + } + void this.notifyMonitor(record).catch((error: unknown) => { + this.log.error('monitor notification delivery failed', { + monitorId: record.monitorId, + error, + }); + }); + } + + private endMonitor(managed: ManagedMonitor): void { + const record = managed.record; + if (record.status !== 'active') return; + record.status = 'ended'; + record.endedAt = Date.now(); + this.cleanupMonitor(managed); + void this.persistRecord(record); + } + + private cleanupMonitor(managed: ManagedMonitor): void { + if (managed.timeoutHandle !== undefined) { + clearTimeout(managed.timeoutHandle); + managed.timeoutHandle = undefined; + } + this.removeWatcher(this.outputWatchers, managed); + this.removeWatcher(this.terminalWatchers, managed); + managed.watchedTaskId = undefined; + managed.watchSubscription?.dispose(); + managed.watchSubscription = undefined; + managed.watchHandle?.dispose(); + managed.watchHandle = undefined; + } + + private async notifyMonitor(record: MonitorRecord): Promise { + const fired = record.fired; + if (fired === undefined) return; + const key = monitorNotificationKey(fired.origin); + if (this.deliveredNotificationKeys.has(key)) return; + const request = new MonitorNotificationStepRequest( + { + role: 'user', + content: [{ type: 'text', text: renderNotificationXml(fired.notification) }], + toolCalls: [], + origin: fired.origin, + }, + () => { + this.fireNotificationHook(fired.notification); + }, + ); + this.pendingNotificationRequests.set(key, request); + try { + const receipt = this.loop.enqueue(request); + void receipt.assigned + .then(({ step }) => step.result) + .then( + () => { + if (request.aborted) this.clearPendingNotification(key, request); + }, + () => { + this.clearPendingNotification(key, request); + }, + ); + } catch (error) { + this.clearPendingNotification(key, request); + throw error; + } + } + + private restoreMonitorNotification(record: MonitorRecord): void { + const fired = record.fired; + if (fired === undefined) return; + const key = monitorNotificationKey(fired.origin); + if (this.scheduledNotificationKeys.has(key)) return; + if (this.deliveredNotificationKeys.has(key)) return; + if (this.hasDeliveredNotification(key)) return; + this.context.append({ + role: 'user', + content: [{ type: 'text', text: renderNotificationXml(fired.notification) }], + toolCalls: [], + origin: fired.origin, + }); + this.fireNotificationHook(fired.notification); + } + + private async reconcileNotificationDeliveryAfterUndo(): Promise { + const restoredKeys = new Set(this.states.get(monitorNotificationDeliveryKey)); + for (const [key, request] of this.pendingNotificationRequests) { + if (request.aborted) this.clearPendingNotification(key, request); + } + this.deliveredNotificationKeys.clear(); + for (const key of restoredKeys) this.deliveredNotificationKeys.add(key); + for (const key of this.scheduledNotificationKeys) { + if (restoredKeys.has(key) || !this.pendingNotificationRequests.has(key)) { + this.scheduledNotificationKeys.delete(key); + } + } + for (const managed of this.monitors.values()) { + if (managed.record.status === 'fired') { + this.restoreMonitorNotification(managed.record); + } + } + } + + private restoreAfterReplay(): Promise { + const restore = this.restoreQueue.then(() => this.restoreAfterReplayNow()); + this.restoreQueue = restore.catch(() => {}); + return restore; + } + + private async restoreAfterReplayNow(): Promise { + const records = await this.loadPersisted(); + for (const record of records) { + if (this.monitors.has(record.monitorId)) continue; + if (record.status === 'active') { + record.status = 'lost'; + record.endedAt = record.endedAt ?? Date.now(); + await this.persistRecord(record); + } + this.monitors.set(record.monitorId, { record, lineRemainder: '' }); + if (record.status === 'fired') { + this.restoreMonitorNotification(record); + } + } + } + + private markDeliveredNotification(origin: MonitorOrigin): void { + const key = monitorNotificationKey(origin); + this.scheduledNotificationKeys.delete(key); + this.pendingNotificationRequests.delete(key); + this.deliveredNotificationKeys.add(key); + } + + private clearPendingNotification(key: string, request: MonitorNotificationStepRequest): void { + if (this.pendingNotificationRequests.get(key) !== request) return; + this.pendingNotificationRequests.delete(key); + if (!this.deliveredNotificationKeys.has(key) && !this.hasDeliveredNotification(key)) { + this.scheduledNotificationKeys.delete(key); + } + } + + private hasDeliveredNotification(key: string): boolean { + return this.context.get().some((message) => { + return isMonitorOrigin(message.origin) && monitorNotificationKey(message.origin) === key; + }); + } + + private fireNotificationHook(notification: MonitorNotification): void { + void this.dispatcher.dispatch( + new MonitorNotified({ + notificationType: notification.type, + title: notification.title, + body: notification.body, + severity: notification.severity, + sourceKind: notification.source_kind, + sourceId: notification.source_id, + }), + ); + } + + private persistenceScope(): string { + return this.scopeContext.scope('monitors'); + } + + private async persistRecord(record: MonitorRecord): Promise { + try { + await this.atomicDocs.set( + this.persistenceScope(), + `${record.monitorId}${MONITOR_DOC_SUFFIX}`, + record, + ); + } catch (error) { + this.log.error('monitor persist failed', { monitorId: record.monitorId, error }); + } + } + + private async loadPersisted(): Promise { + const keys = await this.atomicDocs.list(this.persistenceScope()); + const records: MonitorRecord[] = []; + for (const key of keys) { + if (!key.endsWith(MONITOR_DOC_SUFFIX)) continue; + try { + const record = await this.atomicDocs.get(this.persistenceScope(), key); + if (record !== undefined && typeof record.monitorId === 'string') { + records.push(record); + } + } catch { + } + } + return records.toSorted((a, b) => a.createdAt - b.createdAt); + } + + private toInfo(record: MonitorRecord): MonitorInfo { + return { + monitorId: record.monitorId, + type: record.type, + status: record.status, + description: record.description, + timeoutMs: record.timeoutMs, + createdAt: record.createdAt, + endedAt: record.endedAt, + trigger: record.trigger, + taskId: record.taskId, + pattern: record.pattern, + command: record.command, + path: record.path, + events: record.events, + }; + } +} + +function monitorNotificationTitle(record: MonitorRecord, trigger: MonitorTrigger): string { + switch (trigger) { + case 'match': + return `Monitor matched (${record.type})`; + case 'exit': + return 'Monitored command exited'; + case 'timeout': + return 'Monitor timed out'; + } +} + +function monitorNotificationSeverity( + trigger: MonitorTrigger, + detail: MonitorFireDetail, +): 'info' | 'warning' { + if (trigger === 'match') return 'info'; + if (trigger === 'exit') return detail.exitCode === 0 ? 'info' : 'warning'; + return 'warning'; +} + +function buildMonitorNotificationBody( + record: MonitorRecord, + trigger: MonitorTrigger, + detail: MonitorFireDetail, +): string { + const lines: string[] = []; + if (record.description !== undefined && record.description.length > 0) { + lines.push(`Monitor: ${record.description}`); + } + lines.push(`Type: ${record.type}`); + if (record.command !== undefined) lines.push(`Command: ${record.command}`); + if (record.type === 'task_output' && record.taskId !== undefined) { + lines.push(`Task: ${record.taskId}`); + } + if (record.path !== undefined) lines.push(`Path: ${record.path}`); + if (record.pattern !== undefined) lines.push(`Pattern: ${record.pattern}`); + switch (trigger) { + case 'match': + if (detail.matchedLine !== undefined) lines.push(`Matched line: ${detail.matchedLine}`); + if (detail.changedPath !== undefined) lines.push(`Changed path: ${detail.changedPath}`); + lines.push( + 'The monitor fired on its first match and has ended. Create a new monitor if you need to keep watching.', + ); + break; + case 'exit': + lines.push( + `The monitored command exited (code ${String(detail.exitCode ?? 'unknown')}) before any pattern matched. The monitor has ended.`, + ); + break; + case 'timeout': + lines.push( + `The monitor timed out after ${String(Math.round(record.timeoutMs / 1000))}s without firing. Create a new monitor if you need to keep watching.`, + ); + break; + } + lines.push(`Triggered at: ${new Date(record.endedAt ?? Date.now()).toISOString()}`); + return lines.join('\n'); +} + +function generateMonitorId(): string { + const bytes = randomBytes(8); + let suffix = ''; + for (let index = 0; index < 8; index++) { + suffix += MONITOR_ID_ALPHABET[bytes[index]! % MONITOR_ID_ALPHABET.length]; + } + return `monitor-${suffix}`; +} + +function normalizeSlashes(path: string): string { + return path.replaceAll('\\', '/'); +} + +function staticWatchRoot(absoluteGlob: string): string { + const segments = normalizeSlashes(absoluteGlob).split('/'); + const kept: string[] = []; + for (const segment of segments) { + if (GLOB_MAGIC.test(segment)) break; + kept.push(segment); + } + const root = kept.join('/'); + return root === '' ? '/' : root; +} + +function shellQuote(s: string): string { + return `'${s.replaceAll("'", "'\\''")}'`; +} + +function windowsPathToPosixPath(path: string): string { + if (path.startsWith('\\\\')) { + return path.replaceAll('\\', '/'); + } + const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(path); + if (driveMatch !== null) { + const drive = driveMatch[1]!.toLowerCase(); + const rest = path.slice(2).replaceAll('\\', '/'); + return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`; + } + return path.replaceAll('\\', '/'); +} + +async function disposeQuietly(proc: IHostProcess): Promise { + try { + await proc.dispose(); + } catch { + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentMonitorService, + AgentMonitorService, + ScopeActivation.OnScopeCreated, + 'agentMonitor', +); diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 61e6c008c2..73af68afce 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -1,4 +1,5 @@ import { createDecorator } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; import type { ITaskHandle } from '#/app/task/task'; import type { AgentTask, @@ -35,6 +36,12 @@ export interface RegisterAgentTaskOptions { readonly detachTimeoutMs?: number; readonly autoBackgroundOnTimeout?: boolean; readonly signal?: AbortSignal; + readonly terminalNotificationSuppressed?: boolean; +} + +export interface AgentTaskOutputChunk { + readonly taskId: string; + readonly chunk: string; } export type ForegroundTaskReleaseReason = 'detached' | 'timeout_detached' | 'terminal'; @@ -73,6 +80,8 @@ export interface AgentTaskWaitDelivery { export interface IAgentTaskService { readonly _serviceBrand: undefined; + readonly onDidAppendOutput: Event; + track(handle: ITaskHandle, options: AgentTaskTrackOptions): IAgentTaskEntry; registerTask(task: AgentTask, options?: RegisterAgentTaskOptions): string; getTask(taskId: string): AgentTaskInfo | undefined; diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index d64d699985..76288b9d67 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -6,6 +6,7 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#/kosong/contract/message'; import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/state/state'; import { @@ -49,6 +50,7 @@ import { type AgentTaskLoadOptions, type AgentTask, type AgentTaskInfo, + type AgentTaskOutputChunk, type AgentTaskOutputSnapshot, type AgentTaskStatus, type AgentTaskTrackOptions, @@ -221,6 +223,9 @@ export const taskActiveTaskReminderPendingKey = defineState( export class AgentTaskService extends Disposable implements IAgentTaskService { declare readonly _serviceBrand: undefined; + private readonly appendOutputEmitter = new Emitter(); + readonly onDidAppendOutput = this.appendOutputEmitter.event; + private readonly tasks = new Map(); private readonly buildingNotificationKeys = new Set(); private readonly pendingNotificationRequests = new Map(); @@ -246,6 +251,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @IAgentStateService private readonly states: IAgentStateService, ) { super(); + this._register(this.appendOutputEmitter); this.states.contributeState(taskKey); this.states.contributeState(taskNotificationDeliveryKey); this.states.contributeState(taskGhostsKey); @@ -372,6 +378,8 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { waiters: [], terminalFired: false, timedOut: false, + terminalNotificationSuppressed: + options.terminalNotificationSuppressed === true ? true : undefined, }; this.tasks.set(entry.taskId, entry); this.ghosts.delete(entry.taskId); @@ -956,6 +964,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private appendOutput(entry: ManagedTask, chunk: string): void { + this.appendOutputEmitter.fire({ taskId: entry.taskId, chunk }); const chunkBytes = Buffer.byteLength(chunk, 'utf-8'); entry.outputSizeBytes += chunkBytes; this.appendRetainedOutput(entry, chunk, chunkBytes); diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitor-cancel.md b/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitor-cancel.md new file mode 100644 index 0000000000..c8e4fe69e8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitor-cancel.md @@ -0,0 +1,3 @@ +Cancel an active monitor before it fires. + +Cancelling stops the monitor's watcher and timeout; for `command` monitors it also terminates the monitored command. Cancelling an already finished monitor is a no-op that returns its current state. An unknown `monitor_id` is an error. diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitor-cancel.ts b/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitor-cancel.ts new file mode 100644 index 0000000000..712f4b2ad9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitor-cancel.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const MonitorCancelInputSchema = z.object({ + monitor_id: z.string().describe('The monitor ID to cancel, as returned by MonitorCreate or MonitorList.'), +}); + +export type MonitorCancelInput = z.infer; + +export interface IMonitorCancelTool extends AgentTool { readonly _serviceBrand: undefined } +export const IMonitorCancelTool = createDecorator('monitorCancelTool'); diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitorCancelTool.ts b/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitorCancelTool.ts new file mode 100644 index 0000000000..264565109c --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-cancel/monitorCancelTool.ts @@ -0,0 +1,50 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { matchesGlobRuleSubject } from '#/tool/rule-match'; +import { type ExecutableToolResult, type ToolExecution } from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { IFlagService } from '#/app/flag/flag'; +import { IAgentMonitorService } from '#/agent/monitor/monitor'; +import { MONITOR_FLAG_ID } from '#/agent/monitor/flag'; +import { formatPlainObject } from '#/agent/task/tools/format'; +import { + IMonitorCancelTool, + MonitorCancelInputSchema, + type MonitorCancelInput, +} from './monitor-cancel'; +import MONITOR_CANCEL_DESCRIPTION from './monitor-cancel.md?raw'; + +export class MonitorCancelTool implements IMonitorCancelTool { + declare readonly _serviceBrand: undefined; + readonly name = 'MonitorCancel' as const; + readonly description: string = MONITOR_CANCEL_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(MonitorCancelInputSchema); + + constructor(@IAgentMonitorService private readonly monitors: IAgentMonitorService) {} + + resolveExecution(args: MonitorCancelInput): ToolExecution { + return { + description: `Cancelling monitor ${args.monitor_id}`, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.monitor_id), + execute: () => this.execute(args), + }; + } + + private async execute(args: MonitorCancelInput): Promise { + const info = await this.monitors.cancelMonitor(args.monitor_id); + if (info === undefined) { + return { isError: true, output: `Monitor not found: ${args.monitor_id}` }; + } + return { + isError: false, + output: formatPlainObject(info), + }; + } +} + +registerAgentToolService(IMonitorCancelTool, MonitorCancelTool, { + name: 'MonitorCancel', + domain: 'agentMonitor', + when: (accessor) => accessor.get(IFlagService).enabled(MONITOR_FLAG_ID), +}); diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.md b/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.md new file mode 100644 index 0000000000..f9df8ef46e --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.md @@ -0,0 +1,17 @@ +Register a one-shot monitor that notifies you when an asynchronous event happens, instead of polling with repeated tool calls. + +Monitors are interrupt-driven: when the event fires, a notification is pushed back into your loop automatically. Every monitor is one-shot — it ends after its first notification — and every monitor has a timeout after which it fires a timeout notification. + +Monitor types: + +- `task_output`: watch the stdout/stderr of a running background task owned by this agent. The monitor fires as soon as a line matches `pattern`, without waiting for the task to finish. If the task finishes with no match, the monitor ends silently. +- `command`: run any shell command (for example `tail -f server.log`) as a background task. The monitor fires when a line matches `pattern` (the command is then terminated), or when the command exits on its own, whichever comes first. Omit `pattern` to wait only for the command to exit. +- `file`: watch a file, directory, or glob pattern. The monitor fires on the first matching change (created and/or modified). + +Guidelines: + +- Patterns are matched line by line; do not use anchors or groups that must span multiple lines. +- Prefer monitors over sleep-and-poll loops: they cost no tokens while waiting and react immediately. +- A timeout is a notification, not an error: decide whether to register a new monitor or move on. +- At most 20 monitors can be active at once; use MonitorList to see them and MonitorCancel to stop one early. +- Monitors do not survive a session restart; after a resume they show up as `lost` in MonitorList. diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.ts b/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.ts new file mode 100644 index 0000000000..d116a0e810 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const MONITOR_DEFAULT_TIMEOUT_S = 3600; +export const MONITOR_MAX_TIMEOUT_S = 86400; + +const timeoutField = z + .number() + .int() + .positive() + .max(MONITOR_MAX_TIMEOUT_S) + .optional() + .describe( + `Seconds before the monitor fires a timeout notification (default ${String(MONITOR_DEFAULT_TIMEOUT_S)}, max ${String(MONITOR_MAX_TIMEOUT_S)}).`, + ); + +const descriptionField = z + .string() + .optional() + .describe('Short description of what this monitor is waiting for, shown in notifications.'); + +export const MonitorCreateInputSchema = z.discriminatedUnion('type', [ + z.object({ + type: z + .literal('task_output') + .describe('Watch the stdout/stderr of a background task owned by this agent.'), + task_id: z.string().describe('The background task ID to watch.'), + pattern: z + .string() + .describe( + 'Regular expression matched line by line against the task output. The monitor fires on the first matching line; do not rely on anchors that span multiple lines.', + ), + timeout_s: timeoutField, + description: descriptionField, + }), + z.object({ + type: z + .literal('command') + .describe('Run a shell command (e.g. `tail -f app.log`) and watch its output.'), + command: z.string().describe('The shell command to run.'), + pattern: z + .string() + .optional() + .describe( + 'Regular expression matched line by line against the command output. The monitor fires on the first matching line and then terminates the command. When omitted, the monitor fires when the command exits.', + ), + timeout_s: timeoutField, + description: descriptionField, + }), + z.object({ + type: z.literal('file').describe('Watch a file, directory, or glob for changes.'), + path: z + .string() + .describe( + 'Absolute or cwd-relative path to a file or directory, or a glob pattern (e.g. `dist/**/*.js`).', + ), + events: z + .array(z.enum(['created', 'modified'])) + .optional() + .describe('Which change kinds fire the monitor. Defaults to both created and modified.'), + timeout_s: timeoutField, + description: descriptionField, + }), +]); + +export type MonitorCreateInput = z.infer; + +export interface IMonitorCreateTool extends AgentTool { readonly _serviceBrand: undefined } +export const IMonitorCreateTool = createDecorator('monitorCreateTool'); diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitorCreateTool.ts b/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitorCreateTool.ts new file mode 100644 index 0000000000..595763543a --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitorCreateTool.ts @@ -0,0 +1,117 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { matchesGlobRuleSubject } from '#/tool/rule-match'; +import { type ExecutableToolResult, type ToolExecution } from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { IFlagService } from '#/app/flag/flag'; +import { toErrorMessage } from '#/errors'; +import { + IAgentMonitorService, + MONITOR_MAX_ACTIVE, + type MonitorSpec, +} from '#/agent/monitor/monitor'; +import { MONITOR_FLAG_ID } from '#/agent/monitor/flag'; +import { formatPlainObject } from '#/agent/task/tools/format'; +import { + IMonitorCreateTool, + MONITOR_DEFAULT_TIMEOUT_S, + MonitorCreateInputSchema, + type MonitorCreateInput, +} from './monitor-create'; +import MONITOR_CREATE_DESCRIPTION from './monitor-create.md?raw'; + +function toSpec(args: MonitorCreateInput): MonitorSpec { + const timeoutMs = (args.timeout_s ?? MONITOR_DEFAULT_TIMEOUT_S) * 1000; + const description = args.description; + switch (args.type) { + case 'task_output': + return { + type: 'task_output', + taskId: args.task_id, + pattern: args.pattern, + timeoutMs, + description, + }; + case 'command': + return { + type: 'command', + command: args.command, + pattern: args.pattern, + timeoutMs, + description, + }; + case 'file': + return { type: 'file', path: args.path, events: args.events, timeoutMs, description }; + } +} + +function ruleSubject(args: MonitorCreateInput): string { + switch (args.type) { + case 'task_output': + return args.task_id; + case 'command': + return args.command; + case 'file': + return args.path; + } +} + +export class MonitorCreateTool implements IMonitorCreateTool { + declare readonly _serviceBrand: undefined; + readonly name = 'MonitorCreate' as const; + readonly description: string = MONITOR_CREATE_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(MonitorCreateInputSchema); + + constructor( + @IAgentMonitorService private readonly monitors: IAgentMonitorService, + @IFlagService private readonly flags: IFlagService, + ) {} + + resolveExecution(args: MonitorCreateInput): ToolExecution { + return { + description: `Creating ${args.type} monitor`, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, ruleSubject(args)), + execute: () => this.execute(args), + }; + } + + private async execute(args: MonitorCreateInput): Promise { + if (!this.flags.enabled(MONITOR_FLAG_ID)) { + return { + isError: true, + output: 'MonitorCreate is disabled: the monitor experimental flag is off.', + }; + } + const activeCount = this.monitors + .listMonitors() + .filter((info) => info.status === 'active').length; + if (activeCount >= MONITOR_MAX_ACTIVE) { + return { + isError: true, + output: `Too many active monitors (max ${String(MONITOR_MAX_ACTIVE)}). Cancel one with MonitorCancel before creating another.`, + }; + } + try { + const info = await this.monitors.createMonitor(toSpec(args)); + return { + isError: false, + output: [ + formatPlainObject(info), + '', + info.status === 'active' + ? 'Monitor registered. It is one-shot: you will be notified when it fires, times out, or (for command monitors) the command exits. Use MonitorList to inspect it and MonitorCancel to stop it early.' + : 'The monitor ended immediately because the target task is already finished.', + ].join('\n'), + }; + } catch (error) { + return { isError: true, output: toErrorMessage(error) }; + } + } +} + +registerAgentToolService(IMonitorCreateTool, MonitorCreateTool, { + name: 'MonitorCreate', + domain: 'agentMonitor', + when: (accessor) => accessor.get(IFlagService).enabled(MONITOR_FLAG_ID), +}); diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitor-list.md b/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitor-list.md new file mode 100644 index 0000000000..cc6147f1e3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitor-list.md @@ -0,0 +1,3 @@ +List the monitors registered by this agent. + +Shows every monitor with its id, type, status (`active`, `fired`, `cancelled`, `ended`, `lost`), pattern/command/path, and trigger for fired monitors. Monitors that survived a session restart appear as `lost` and never fire again. diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitor-list.ts b/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitor-list.ts new file mode 100644 index 0000000000..1cddeb1a88 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitor-list.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const MonitorListInputSchema = z.object({}); + +export type MonitorListInput = z.infer; + +export interface IMonitorListTool extends AgentTool { readonly _serviceBrand: undefined } +export const IMonitorListTool = createDecorator('monitorListTool'); diff --git a/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitorListTool.ts b/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitorListTool.ts new file mode 100644 index 0000000000..6f375e731c --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-list/monitorListTool.ts @@ -0,0 +1,47 @@ +import { toInputJsonSchema } from '#/tool/input-schema'; +import { type ExecutableToolResult, type ToolExecution } from '#/tool/toolContract'; +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; + +import { IFlagService } from '#/app/flag/flag'; +import { IAgentMonitorService } from '#/agent/monitor/monitor'; +import { MONITOR_FLAG_ID } from '#/agent/monitor/flag'; +import { formatPlainObject } from '#/agent/task/tools/format'; +import { IMonitorListTool, MonitorListInputSchema, type MonitorListInput } from './monitor-list'; +import MONITOR_LIST_DESCRIPTION from './monitor-list.md?raw'; + +export class MonitorListTool implements IMonitorListTool { + declare readonly _serviceBrand: undefined; + readonly name = 'MonitorList' as const; + readonly description: string = MONITOR_LIST_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(MonitorListInputSchema); + + constructor(@IAgentMonitorService private readonly monitors: IAgentMonitorService) {} + + resolveExecution(_args: MonitorListInput): ToolExecution { + return { + description: 'Listing monitors', + approvalRule: this.name, + execute: () => this.execute(), + }; + } + + private execute(): Promise { + const infos = this.monitors.listMonitors(); + if (infos.length === 0) { + return Promise.resolve({ + isError: false, + output: 'No monitors registered. Use MonitorCreate to register one.', + }); + } + return Promise.resolve({ + isError: false, + output: infos.map((info) => formatPlainObject(info)).join('\n---\n'), + }); + } +} + +registerAgentToolService(IMonitorListTool, MonitorListTool, { + name: 'MonitorList', + domain: 'agentMonitor', + when: (accessor) => accessor.get(IFlagService).enabled(MONITOR_FLAG_ID), +}); diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index ac4153cfa7..15949eef82 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -243,6 +243,23 @@ export interface WaitForCompletedEvent { extra_completed_count: number; } +export interface MonitorCreatedEvent { + monitor_type: 'task_output' | 'command' | 'file'; + has_pattern: boolean; + timeout_ms: number; +} + +export interface MonitorFiredEvent { + monitor_type: 'task_output' | 'command' | 'file'; + trigger: 'match' | 'exit' | 'timeout'; + duration_ms: number; +} + +export interface MonitorCancelledEvent { + monitor_type: 'task_output' | 'command' | 'file'; + duration_ms: number; +} + export interface ModelSwitchEvent { model: string; } @@ -702,6 +719,32 @@ export const telemetryEventDefinitions = { extra_completed_count: 'Number of additional tasks that finished within the wait window', }, }), + monitor_created: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'A monitor is registered.', + properties: { + monitor_type: 'Monitor type: task_output, command, or file', + has_pattern: 'Whether the monitor matches output with a regular expression', + timeout_ms: 'Monitor timeout in milliseconds', + }, + }), + monitor_fired: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'A monitor fires its one-shot notification.', + properties: { + monitor_type: 'Monitor type: task_output, command, or file', + trigger: 'What fired the monitor: a pattern match, the command exiting, or the timeout', + duration_ms: 'Monitor wall-clock time from creation to firing in milliseconds', + }, + }), + monitor_cancelled: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'A monitor is cancelled before firing.', + properties: { + monitor_type: 'Monitor type: task_output, command, or file', + duration_ms: 'Monitor wall-clock time from creation to cancellation in milliseconds', + }, + }), model_switch: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The active model is bound or switched.', diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index f776d68afa..c36c61e82b 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -15,6 +15,7 @@ import { GoalErrors } from '#/agent/goal/errors'; import { LoopErrors } from '#/agent/loop/errors'; import { McpErrors } from '#/mcpCore/errors'; import { ModelCatalogErrors } from '#/kosong/model/errors'; +import { MonitorErrors } from '#/agent/monitor/errors'; import { OsFsErrors } from '#/os/interface/hostFsErrors'; import { OsProcessErrors } from '#/os/interface/hostProcess'; import { PluginErrors } from '#/app/plugin/errors'; @@ -52,6 +53,7 @@ export { GoalErrors } from '#/agent/goal/errors'; export { LoopErrors } from '#/agent/loop/errors'; export { McpErrors } from '#/mcpCore/errors'; export { ModelCatalogErrors } from '#/kosong/model/errors'; +export { MonitorErrors } from '#/agent/monitor/errors'; export { OsFsErrors } from '#/os/interface/hostFsErrors'; export { OsProcessErrors } from '#/os/interface/hostProcess'; export { PluginErrors } from '#/app/plugin/errors'; @@ -87,6 +89,7 @@ export const ErrorCodes = { ...LoopErrors.codes, ...McpErrors.codes, ...ModelCatalogErrors.codes, + ...MonitorErrors.codes, ...OsFsErrors.codes, ...OsProcessErrors.codes, ...PluginErrors.codes, diff --git a/packages/agent-core-v2/src/features/plan/injection/plan-mode-full-reminder.md b/packages/agent-core-v2/src/features/plan/injection/plan-mode-full-reminder.md index ee2fc63094..322320d965 100644 --- a/packages/agent-core-v2/src/features/plan/injection/plan-mode-full-reminder.md +++ b/packages/agent-core-v2/src/features/plan/injection/plan-mode-full-reminder.md @@ -1,4 +1,4 @@ -Plan mode is active. You MUST NOT make any edits (with the exception of the current plan file) or otherwise make changes to the system unless a tool request is explicitly approved. Prefer read-only tools. Use Bash only when needed; Bash follows the normal permission mode and rules. This supersedes any other instructions you have received. TaskStop, CronCreate, and CronDelete are also blocked in plan mode — call ExitPlanMode first if you need them. +Plan mode is active. You MUST NOT make any edits (with the exception of the current plan file) or otherwise make changes to the system unless a tool request is explicitly approved. Prefer read-only tools. Use Bash only when needed; Bash follows the normal permission mode and rules. This supersedes any other instructions you have received. TaskStop, CronCreate, CronDelete, MonitorCreate, and MonitorCancel are also blocked in plan mode — call ExitPlanMode first if you need them. Workflow: 1. Understand — explore the codebase with Glob, Grep, Read. diff --git a/packages/agent-core-v2/src/features/plan/planService.ts b/packages/agent-core-v2/src/features/plan/planService.ts index 203ea4d9dc..5da7fe6b86 100644 --- a/packages/agent-core-v2/src/features/plan/planService.ts +++ b/packages/agent-core-v2/src/features/plan/planService.ts @@ -139,6 +139,17 @@ export class AgentPlanService extends Service implements IAgentPlanService { ); return; } + + if (toolName === 'MonitorCreate' || toolName === 'MonitorCancel') { + event.veto( + denyToolExecution( + this.toolApproval.formatDenyMessage( + `${toolName} is not available in plan mode because it would register or cancel a monitor that fires after plan exit. Call ExitPlanMode first.`, + ), + ), + ); + return; + } } private get isActive(): boolean { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 0995dc5b2f..6457b19dc9 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -403,6 +403,15 @@ import '#/agent/tools/task/task-wait/taskWaitTool'; export * from '#/agent/task/task'; export * from '#/agent/task/taskOps'; export * from '#/agent/task/taskService'; +export * from '#/agent/monitor/errors'; +export * from '#/agent/monitor/monitor'; +export * from '#/agent/monitor/monitorService'; +export * from '#/agent/tools/monitor/monitor-create/monitor-create'; +import '#/agent/tools/monitor/monitor-create/monitorCreateTool'; +export * from '#/agent/tools/monitor/monitor-list/monitor-list'; +import '#/agent/tools/monitor/monitor-list/monitorListTool'; +export * from '#/agent/tools/monitor/monitor-cancel/monitor-cancel'; +import '#/agent/tools/monitor/monitor-cancel/monitorCancelTool'; import '#/app/cron/configSection'; export * from '#/app/cron/cronTask'; export * from '#/app/cron/cronTaskPersistence'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 77f26482f0..8ecec7b061 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -23,6 +23,9 @@ const AGENT_TOOLS = [ 'CronCreate', 'CronList', 'CronDelete', + 'MonitorCreate', + 'MonitorList', + 'MonitorCancel', 'ReadMediaFile', 'TodoList', 'Skill', diff --git a/packages/agent-core-v2/src/tool/input-schema.ts b/packages/agent-core-v2/src/tool/input-schema.ts index 085ffad401..f39ef1b7e6 100644 --- a/packages/agent-core-v2/src/tool/input-schema.ts +++ b/packages/agent-core-v2/src/tool/input-schema.ts @@ -6,9 +6,16 @@ export function toInputJsonSchema(schema: z.ZodType): Record { io: 'input', }); closeObjectNodes(jsonSchema); + rootObjectType(jsonSchema); return jsonSchema; } +function rootObjectType(jsonSchema: Record): void { + if (jsonSchema['type'] !== undefined) return; + if (!Array.isArray(jsonSchema['anyOf']) && !Array.isArray(jsonSchema['oneOf'])) return; + jsonSchema['type'] = 'object'; +} + function closeObjectNodes(value: unknown): void { if (Array.isArray(value)) { for (const item of value) closeObjectNodes(item); diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index d371f68478..e10eca4584 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -291,7 +291,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 3_302, + tokens_before: 3_312, tokens_after: expect.any(Number), duration_ms: expect.any(Number), compacted_count: 6, @@ -570,7 +570,7 @@ describe('FullCompaction', () => { session_id: 'test-session', cwd: dir, trigger: 'auto', - token_count: 3_302, + token_count: 3_312, }); expect(post).toMatchObject({ hook_event_name: 'PostCompact', @@ -656,7 +656,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'manual', - tokens_before: 14_980, + tokens_before: 14_990, retry_count: 1, trace_id: 'trace-compact-1', }), @@ -1039,7 +1039,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 14_980, + tokens_before: 14_990, duration_ms: expect.any(Number), round: 1, retry_count: 0, @@ -1264,7 +1264,7 @@ describe('FullCompaction', () => { event: 'compaction_failed', properties: expect.objectContaining({ source: 'manual', - tokens_before: 14_980, + tokens_before: 14_990, duration_ms: expect.any(Number), retry_count: 4, error_type: 'APIConnectionError', @@ -1637,8 +1637,8 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - tokens_before: 3_309, - tokens_after: 3_293, + tokens_before: 3_319, + tokens_after: 3_303, compacted_count: 7, retry_count: 0, }), diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index eeaf5d1828..a254d8d00b 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -134,8 +134,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "time": "