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/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 65426e7aef..ca24cab3ad 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -85,6 +85,7 @@ Per-domain references live in `docs/`. - [`docs/errors.md`](docs/errors.md) — Read **before raising errors from a domain**: defining a co-located `XxxError`, registering a code in `ErrorCodes`/`ERROR_INFO`, translating external errors (provider/HTTP, fs, MCP) at the boundary, or (de)serializing errors across RPC/SDK with `toErrorPayload`/`fromErrorPayload`. - [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`. - [`docs/features.md`](docs/features.md) — Read **before adding or extracting a built-in feature** (`src/features//`): the `Feature` base class, the `contribute*` seams, the static-vs-feature channel rules, and the assembly/retraction lifecycle. +- [`docs/monitor.md`](docs/monitor.md) — The experimental Monitor capability (`MonitorCreate`/`MonitorList`/`MonitorCancel` one-shot watchers): watcher semantics, the dual-engine architecture, contracts, and the real-CLI verification record. - [`docs/config-manifest.toml`](docs/config-manifest.toml) — Generated list of every registered config section, in the on-disk `config.toml` shape (owner, scope, defaults, env bindings, schema fields). Do not edit by hand; regenerate with `pnpm gen:config-manifest` after adding or removing a `registerConfigSection` call — `test/app/config/configManifest.test.ts` enforces freshness. - [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every durable wire record type (an `Event2` subclass with `static durable = true` + `static schema`) as a payload interface (folding states, blob codec owners, and owner file in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a durable `Event2` class — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses. - [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `IAppStateService` / `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, as `AppStateSnapshot` / `WorkspaceStateSnapshot` / `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `AppStateKey` / `WorkspaceStateKey` / `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.contributeState(...)` call or a `defineState(...).replayable(...)` key (the Agent section covers the replayable keys contributed by their owner services, with each replayable key's fold/durable/undoable info) — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses. diff --git a/packages/agent-core-v2/docs/monitor.md b/packages/agent-core-v2/docs/monitor.md new file mode 100644 index 0000000000..2b52243707 --- /dev/null +++ b/packages/agent-core-v2/docs/monitor.md @@ -0,0 +1,94 @@ +# monitor + +> Agent 的一次性事件监听器——通过 `MonitorCreate` / `MonitorList` / `MonitorCancel` 三个工具注册,事件触发时以中断通知推回 agent 主循环,替代轮询。实验特性,由 `monitor` flag 控制(`KIMI_CODE_EXPERIMENTAL_MONITOR`,默认关闭)。双引擎实现:v1(`packages/agent-core`)的 `MonitorManager` 与 v2(`packages/agent-core-v2`,本包)的 `AgentMonitorService`。 + +设计对标 Claude Code 的 Monitor 工具:agent 注册对异步事件的监听,引擎在事件发生时把通知**推回**主循环,取代轮询(反复调 TaskOutput、sleep 检查)——轮询烧 token、撑上下文、引入延迟。 + +## 监听器类型 + +| 类型 | 监听对象 | 触发条件 | +| --- | --- | --- | +| `task_output` | 本 agent 后台任务的 stdout/stderr | 首个匹配 `pattern` 的输出行(**不等**任务结束);任务无匹配而终则静默结束 | +| `command` | 以后台任务运行的任意 shell 命令(如 `tail -f app.log`) | 首个匹配行(随后杀掉命令进程)或命令退出,先到先触发;可省略 `pattern` 只等退出 | +| `file` | 文件、目录(递归)或 glob | 首个匹配的 `created` / `modified` 事件;可选 `pattern`(对变化文件路径做 regex 过滤)与 path/glob 组合生效 | + +三类共享的语义: + +- **一次性(one-shot)**:`match` / `exit` / `timeout` 三者先到先触发,只发一次通知即关闭。要继续监听需重新创建。 +- **超时**:每个监听器带 `timeout` 输入(秒,默认 3600,上限 86400)。超时是一种通知,不是错误。 +- **历史输出匹配**(`task_output`):订阅时会把任务**已产生**的输出回放一遍匹配器,模型从"启动任务"到"注册监听器"之间的延迟不会错过关键行。 +- **上限**:每 agent 最多 20 个活跃监听器。 +- **不跨重启存活**:持久化的监听器在会话恢复后一律标记 `lost`(MonitorList 可见,不重挂)。command 监听器重跑等于隐式重执行任意 shell(有副作用,不可接受);停机期间的文件事件本就观测不到;`task_output` 的目标任务自身也是 lost。 + +## 工具契约 + +`MonitorCreate` 是以 `type` 判别的 zod discriminated union: + +- 公共字段:`timeout`(秒,可选,默认 3600,上限 86400)、`description`(可选) +- `{ type: 'task_output', task_id, pattern }` +- `{ type: 'command', command, pattern? }` +- `{ type: 'file', path, events?, pattern? }` + +两个输入命名决策由模型人体工学驱动(见"真机验证发现的 bug"): + +- 超时字段命名为 `timeout`(秒),与 `Bash` 工具的惯例对齐——模型被 Bash 引导,之前用 `timeout_s` 时反复被 schema 拒绝; +- `file` 分支接受 `pattern`,因为模型会自然地从另外两个分支泛化出这个参数。 + +`MonitorList` 无参,列出所有监听器及状态(`active` / `fired` / `ended` / `cancelled` / `lost`)。`MonitorCancel` 收 `id`(`MonitorCreate` 返回的 `monitor-*` id)。 + +## v2 架构 + +- `src/agent/monitor/monitor.ts` —— 契约:`IAgentMonitorService` decorator、spec/info/notification 类型、`MonitorNotified` 事件、state keys。`monitorNotificationDeliveryKey` 为 `defineState(...).replayable().undoable()`(镜像 `taskNotificationDeliveryKey`);另有两个普通 key 跟踪 scheduled/delivered 通知集合。 +- `src/agent/monitor/monitorService.ts` —— `AgentMonitorService`,Agent scope,`ScopeActivation.OnScopeCreated`。**必须 eager**:replayable state key 必须在 dispatcher `restore()` 完成之前贡献,而 `OnDemand` 服务首次被拉取时窗口早已关闭(这是真机验证抓到的真实 bug,见下文)。 +- 通知走 `MonitorNotificationStepRequest extends MessageStepRequest`(kind `monitor_notification`、`mergeable`、`admission: 'activeOrNewTurn'`),经 `IAgentLoopService.enqueue` 注入,镜像 task notification 路径。origin 为 prompt-origin 联合的 `MonitorOrigin` 分支:`{ kind: 'monitor', monitorId, monitorType, trigger, notificationId }`。 +- `task_output` watcher 订阅 `IAgentTaskService.onDidAppendOutput`(在 `appendOutput` 顶部、ring buffer 截断与 16MiB 强杀判断之前 fire),随后用 `readOutput(taskId)` 回放历史输出。跨 chunk 行重组(`\n` 切分、残段 4KiB 上限),逐行匹配。 +- `command` watcher 复用 `ProcessTask` 并以 `terminalNotificationSuppressed: true` 注册,继承任务生命周期(16MiB 上限、SIGTERM → SIGKILL、close 自动收尾、`TaskList` 可见)。 +- `file` watcher 走 `IHostFsWatchService`(chokidar)。watch 路径经最近现存祖先的 realpath 规范化(`canonicalizeForWatch`)——chokidar 监视穿过符号链接的路径时(macOS `/tmp` → `/private/tmp`)会把变更报在符号链接节点上,导致精确路径比较和 glob 过滤双双失效。glob 支持 = 静态前缀目录递归 watch + picomatch 过滤(chokidar v4 无 glob)。 +- 送达记账镜像 taskService:scheduled/delivered key 集合、`onDidRestore` 重放、`ContextSpliced` 标记、undo participant、resume 时重投"已 fire 未送达"的通知。 +- 持久化用 `IAtomicDocumentStore`(遵守本包 persistence 规则,不碰 `node:fs`)。 +- 遥测:`monitor_created` / `monitor_fired` / `monitor_cancelled` 注册于 `src/app/telemetry/events.ts`(`track2`,属性不含路径与用户内容)。 +- 工具在 `src/agent/tools/monitor/monitor-{create,list,cancel}/`,以 `registerAgentToolService(..., { domain: 'agentMonitor', when: flag })` 自注册;flag 声明于 `src/agent/monitor/flag.ts`。 + +## v1 对应实现 + +`packages/agent-core/src/agent/monitor/manager.ts` —— `MonitorManager`,仅挂主 agent(子 agent 为 `null`,与 cron 同模式)。通知复用 `renderNotificationXml`(category `monitor`),经 `agent.turn.steer` 推送,origin 形状与 v2 一致;同时发 `Notification` hook。持久化用 `PerIdJsonStore` 写 `/monitors/.json`。工具在 `packages/agent-core/src/tools/monitor/`,按 `experimentalFlags.enabled('monitor')` 条件实例化。 + +## 跨包触点 + +- `packages/protocol/src/events.ts`、`packages/kap-server/src/protocol/events-zod.ts` —— origin zod 联合的 `monitor` 分支(wire 校验会拒收未知 origin,必须最先落地)。 +- `packages/transcript` —— `TurnOrigin` 联合、wire 契约 schema、`groupTurns` 映射。 +- `packages/kap-server/src/services/transcript/coreEventMap.ts` —— origin 映射。 +- `apps/kimi-code` TUI —— replay/导出渲染 monitor 通知(**live** 渲染路径尚缺,见"已知限制")。 +- plan mode:双引擎均在 plan mode 下封禁 `MonitorCreate` / `MonitorCancel`(v1 `plan-mode-guard-deny.ts` + injection 文案;v2 `planService.ts` + `plan-mode-full-reminder.md`)——注册监听器是会越过 plan 退出的副作用。 + +## 验证 + +- v1:`packages/agent-core/test/agent/monitor/monitor.test.ts`(单元 + 工具面 + 真实 Agent + scripted model 的端到端:`MonitorCreate → 进程启动 → 命中 → steer → 进程收尾`)。 +- v2:`packages/agent-core-v2/test/agent/monitor/monitorService.test.ts`(DI harness 挂真实 `AgentTaskService`,含驱动真实 dispatcher restore 时序的回归用例)。 +- schema 转换:`test/tools/input-schema-io.test.ts`(v1)与 `test/tool/input-schema.test.ts`(v2)锁定 union 根必须暴露 `type: "object"`。 +- parity:`packages/node-sdk/test/v1-v2-parity.test.ts` 未改动即通过(flag 默认关,两引擎工具清单都不含新工具)。 +- 真机:构建产物带 flag 跑三监听器完整演示(后台任务 + task_output 监听、`tail -f` 命令监听、文件创建监听),三个通知全部在 turn 中送达。 + +## 真机验证发现的 bug + +以下每一个都通过了全部单测,只有跑构建产物才暴露: + +1. **replayable state 时序违例** —— v2 服务原为 `ScopeActivation.OnDemand`,`contributeState` 在 dispatcher 进入 `ready` 后才执行,agent 创建即抛 `BugIndicatingError`。修复:改 eager(`OnScopeCreated`),对齐 `AgentTaskService`。 +2. **union 工具 schema 被 provider 拒绝** —— zod discriminated union 序列化后根级只有 `anyOf` 分支列表、没有 `type`,provider 要求 `tools.function.parameters.type === "object"`(400)。修复:双引擎 `toInputJsonSchema` 统一给 `anyOf`/`oneOf` 根补 `type: 'object'`。 +3. **`task_output` 历史输出竞态** —— watcher 原来只看订阅之后的 chunk,模型"思考"几秒才注册时,关键行早已输出,监听器永远错过并静默结束。修复:订阅时回放任务持久化输出。 +4. **符号链接 watch 路径** —— macOS 上监听 `/tmp/monitor-demo.done` 不触发,chokidar 把变更报在 `/tmp` 符号链接节点上。修复:双引擎统一做 watch 路径 realpath 规范化。 +5. **schema 人体工学** —— 模型被 Bash 工具引导传 `timeout`(字段却叫 `timeout_s`);调 file 类型时自然带上 `pattern`(分支却没有)。修复:输入改名 `timeout`;file 分支接受 `pattern`。教训:closed-object union schema 会惩罚每一个合理猜测,面向模型的字段名应遵循工具集已有的惯例。 + +## 已知限制 + +- **无 live TUI 渲染**:monitor 通知在 replay/导出中可见,但没有 cron `cron.fired` 那样的专属 live 渲染事件;交互会话里用户看到的是 agent "自发"开始新回合。 +- 监听器恢复后为 `lost`(设计如此,见上文),模型需在重启后重建。 +- `task_output` 逐行匹配、残段上限 4KiB;不支持跨行 pattern(设计如此)。 +- `file` 监听器通知里报告的是规范化(realpath 后)的变化路径。 + +## 提交(分支 `feat/monitor-watchers`) + +- `35e2fdf8f` feat: add experimental Monitor tools for event-driven watchers +- `a20d6c5d7` fix: close two monitor watcher gaps found in real CLI runs(backlog 回放、符号链接规范化) +- `84080e9f0` fix: rename MonitorCreate's `timeout_s` input to `timeout` +- `4f81ee215` feat: accept an optional `pattern` on file monitors 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..17ba931041 --- /dev/null +++ b/packages/agent-core-v2/src/agent/monitor/monitor.ts @@ -0,0 +1,150 @@ +/* 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[]; + readonly pattern?: string; +} + +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..9a7413b59e --- /dev/null +++ b/packages/agent-core-v2/src/agent/monitor/monitorService.ts @@ -0,0 +1,895 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync, realpathSync } from 'node:fs'; + +import picomatch from 'picomatch'; +import { basename, dirname, join, 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); + void this.tasks.readOutput(spec.taskId).then( + (backlog) => { + this.feedChunk(managed, backlog); + }, + () => {}, + ); + } + + 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 = canonicalizeForWatch(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; + const pathPattern = spec.pattern === undefined ? undefined : this.compilePattern(spec.pattern); + record.pattern = spec.pattern; + managed.matchesFile = (changedPath: string): boolean => { + const normalized = normalizeSlashes(changedPath); + if (matcher !== undefined) { + if (!matcher(normalized)) return false; + } else { + const target = normalizeSlashes(absolute); + if (normalized !== target && !normalized.startsWith(`${target}/`)) return false; + } + if (pathPattern !== undefined && !pathPattern.test(normalized)) return false; + return true; + }; + 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 canonicalizeForWatch(target: string): string { + const missing: string[] = []; + let current = target; + for (;;) { + if (existsSync(current)) { + try { + const real = realpathSync(current); + return missing.length === 0 ? real : join(real, ...missing); + } catch { + return target; + } + } + const parent = dirname(current); + if (parent === current) return target; + missing.unshift(basename(current)); + current = parent; + } +} + +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..b71f353c40 --- /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; output produced before the monitor was created is matched as well. 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). Optional `pattern` (regular expression) further filters which changed file paths fire — e.g. path `logs/` + pattern `\.log$`. + +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..e72e17dc37 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitor-create.ts @@ -0,0 +1,77 @@ +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: 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: 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.'), + pattern: z + .string() + .optional() + .describe( + 'Regular expression matched against the changed file path — only matching changes fire the monitor. Omit to fire on every change under path.', + ), + timeout: 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..dac81ed874 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/monitor/monitor-create/monitorCreateTool.ts @@ -0,0 +1,124 @@ +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 ?? 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, + pattern: args.pattern, + 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": "