diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 90e1dc10242..c84111259f1 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -101,7 +101,7 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk. - `src/kosong/model/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper and the authoritative `ThinkingConfig` type (the `thinking` section itself registers from `src/app/kosongConfig/configSection.ts`). - `src/app/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. -A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/app/flag/flag.ts` for `experimental`, `src/agent/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` and `secondaryModel` have no kosong-side type at all — their sections are fully self-contained in `app/kosongConfig`, types derived from the schemas.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`; the `[secondary_model]` derived-entry synthesis in `secondaryModelOverlay.ts`) and is registered via module-level `registerConfigOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). +A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/app/flag/flag.ts` for `experimental`, `src/agent/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` has no kosong-side type at all — its section is fully self-contained in `app/kosongConfig`, types derived from the schema.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis in `src/app/kosongConfig/envOverlay.ts`) lives in the wrapper too and is registered via module-level `registerConfigOverlay`. The session subagent domain owns two sections in `src/session/subagent/configSection.ts`: `[subagent]` (`timeout_ms` on disk) and `[secondary_model]` (`default_model` plus the `[secondary_model.models]` pool, with a lone legacy v1 `model` key honored as a fallback default below `default_model`); neither carries a cross-section overlay. Cross-field pool validation (default present / in-pool / every key resolvable) runs at session creation in `subagentModelsValidationService.ts`, not in the schema. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). ## Scope diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 5039201ac95..0c8e6652d38 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -45,7 +45,7 @@ A Service method is directly exposable iff **all** hold: 3. Errors are `KimiError` (coded). 4. It is a command/query, not a factory, stream, byte-store, or sink. -If any fail → wrap in a **facade** (a Service that takes ids, returns data, throws `KimiError`) and expose the facade. The repo already ships a wire-shaped facade in `rpc/core-api.ts` (`CoreAPI` / `SessionAPI` / `AgentAPI`) behind `IAgentRPCService` / `ISessionRPCService` — prefer building the HTTP edge on top of it rather than re-deriving a new one. +If any fail → add a wire-safe orchestration method to the owning domain Service (e.g. `IAgentPromptService.submit` settles `{turn_id}` instead of returning the live `PromptHandle`) or compose several domain Services at the edge — kap-server's `routes/prompts.ts` is the reference for edge-side composition. ## 3. Per-scope `resource:action` map diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index 6907a710a22..32a8948a4b7 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -165,7 +165,7 @@ const route = defineRoute( app.post(route.path, route.options, route.handler); ``` -**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), wrap it in a wire-shaped facade first (`IAgentRPCService` / `ISessionRPCService`) and map to the facade — as `prompts:*` does via `IAgentRPCService`. +**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), add a wire-safe orchestration method to the owning domain Service first — as `prompts:submit` maps to `IAgentPromptService.submit`, which settles `{turn_id}` engine-side instead of returning the live `PromptHandle`. ### 5. Map errors @@ -218,7 +218,7 @@ This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:si **The split.** -- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IAgentPromptService` is untouched. +- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to the domain Services (`IAgentPromptService.submit` / `submitSteer`, `IAgentConversationUndoService.undo`, `IAgentLoopService.cancelFromUser`) in `actionMap`. - `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService. **The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from the shared v1 wire schemas (see `packages/kap-server/src/protocol`). The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes. diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index 5484f48edca..6aef868243a 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -51,7 +51,7 @@ File names derive from the interface / class names so that scope and role are vi | Shared-types file | `.types.ts` | `log.types.ts` | | Errors file | `.errors.ts` | `appendLogStore.errors.ts` | -Acronym-aware lowerCamelCase lowercases a leading acronym as a group: `ILLMRequester` → `llmRequester.ts`, `IWSGateway` → `wsGateway.ts`, `IOAuthToolkit` → `oauthToolkit.ts`, `IAgentRPCService` → `agentRpcService.ts`. +Acronym-aware lowerCamelCase lowercases a leading acronym as a group: `ILLMRequester` → `llmRequester.ts`, `IWSGateway` → `wsGateway.ts`, `IOAuthToolkit` → `oauthToolkit.ts`, `IMcpServerService` → `mcpServerService.ts`. Because the impl class always ends in `Service` and the interface file never does, the two files of one service never collide — even for `Store` / `Registry` / `Resolver` interfaces (`IAppendLogStore` → `appendLogStore.ts` + `appendLogStoreService.ts`). diff --git a/.changeset/agent-core-v2-session-title.md b/.changeset/agent-core-v2-session-title.md new file mode 100644 index 00000000000..17df2875d60 --- /dev/null +++ b/.changeset/agent-core-v2-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": minor +--- + +Add the Session-scoped `ISessionTitleService` for managed AI session titles: composes the excerpt sent to the platform chat_title tool from the main agent's conversation (the first user prompts, the strict `first_turn` pair, or the head+tail `digest` for multi-turn sessions; assistant segments keep only final text), persists the result with a `titleKind` (`replaceable` / `generated` / `custom`) that never overwrites a user-renamed title unless explicitly forced, and rebroadcasts `session.meta.updated`. Gated by the new experimental `auto_session_title` flag and a managed OAuth login. diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md new file mode 100644 index 00000000000..253b97ff021 --- /dev/null +++ b/.changeset/auto-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add `generateSessionTitle` (v2 engine) for managed AI session titles: optional `force` regeneration over generated/custom titles and selectable conversation excerpts (`user_prompts` / `first_turn` / `digest`). Gated by the experimental `auto_session_title` flag and a managed OAuth login. diff --git a/.changeset/calm-mcp-auth-probe.md b/.changeset/calm-mcp-auth-probe.md deleted file mode 100644 index 989a0b6ddda..00000000000 --- a/.changeset/calm-mcp-auth-probe.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch -"@moonshot-ai/kimi-code-sdk": patch ---- - -Detect MCP servers that require OAuth by reusing the existing connection-time authorization check. diff --git a/.changeset/compaction-token-full-request-basis.md b/.changeset/compaction-token-full-request-basis.md deleted file mode 100644 index 39b1b23d18d..00000000000 --- a/.changeset/compaction-token-full-request-basis.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix the token counts reported after compaction reading far below the real context size: the before/after stats and the context gauge now include the system prompt and tool definitions, matching the numbers shown while the session runs. diff --git a/.changeset/fix-banner-long-tag-narrow-terminal.md b/.changeset/fix-banner-long-tag-narrow-terminal.md new file mode 100644 index 00000000000..24607f482e3 --- /dev/null +++ b/.changeset/fix-banner-long-tag-narrow-terminal.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix startup banner text wrapping on narrow terminals. diff --git a/.changeset/fix-mcp-oauth-cancel.md b/.changeset/fix-mcp-oauth-cancel.md new file mode 100644 index 00000000000..07f295679db --- /dev/null +++ b/.changeset/fix-mcp-oauth-cancel.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix MCP OAuth cancellation leaving an in-flight authorization waiting for its callback timeout. diff --git a/.changeset/fix-tui-startup-freeze.md b/.changeset/fix-tui-startup-freeze.md deleted file mode 100644 index 71991eb225b..00000000000 --- a/.changeset/fix-tui-startup-freeze.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix multi-second typing and rendering freezes at startup or while idle when a large search index loads, replays, or rebuilds. diff --git a/.changeset/fix-windows-project-root-watch.md b/.changeset/fix-windows-project-root-watch.md new file mode 100644 index 00000000000..27fd36caab6 --- /dev/null +++ b/.changeset/fix-windows-project-root-watch.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix repeated file-watcher errors on Windows when the workspace is a drive root (such as `E:\`) or a UNC network share. diff --git a/.changeset/isolate-session-profile-catalogs.md b/.changeset/isolate-session-profile-catalogs.md deleted file mode 100644 index fa8483dacd1..00000000000 --- a/.changeset/isolate-session-profile-catalogs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Prevent one session's subagent tool projection from changing builtin profiles in later sessions. diff --git a/.changeset/kap-server-session-title-route.md b/.changeset/kap-server-session-title-route.md new file mode 100644 index 00000000000..2c4ab910833 --- /dev/null +++ b/.changeset/kap-server-session-title-route.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kap-server": patch +--- + +Add `POST /api/v1/sessions/{session_id}/title/generate` with an optional `{ "force": true, "source": "user_prompts" | "first_turn" | "digest" }` body; unknown sessions return 40401 and unavailable generation (flag off, no managed login, no prompt yet, backend failure) returns the new 40923 SESSION_TITLE_UNAVAILABLE. diff --git a/.changeset/klient-session-title.md b/.changeset/klient-session-title.md new file mode 100644 index 00000000000..833b46b1cc5 --- /dev/null +++ b/.changeset/klient-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/klient": patch +--- + +Expose `session(id).generateTitle({ force, source })` on the session facade and the matching `sessionTitleService` wire contract. diff --git a/.changeset/oauth-chat-title.md b/.changeset/oauth-chat-title.md new file mode 100644 index 00000000000..329c6fe18f4 --- /dev/null +++ b/.changeset/oauth-chat-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-oauth": patch +--- + +Add `fetchChatTitle` for the managed platform `/tools` `chat_title` method: protocol headers, an 8s timeout, response validation, and structured failures. diff --git a/.changeset/oauth-device-subpath-export.md b/.changeset/oauth-device-subpath-export.md new file mode 100644 index 00000000000..8c43547c1f8 --- /dev/null +++ b/.changeset/oauth-device-subpath-export.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-oauth": minor +--- + +Add a browser-safe `./device` subpath export exposing the device-code flow's pure-fetch HTTP wrappers and flow config, so browser bundles can run OAuth sign-in without pulling in Node-only modules. Import from `@moonshot-ai/kimi-code-oauth/device`. diff --git a/.changeset/quiet-mcp-auth-status.md b/.changeset/quiet-mcp-auth-status.md deleted file mode 100644 index 891a3e9903c..00000000000 --- a/.changeset/quiet-mcp-auth-status.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code-sdk": patch ---- - -Expose persisted MCP authorization status without starting an OAuth flow. diff --git a/apps/kimi-code/AGENTS.md b/apps/kimi-code/AGENTS.md index 11184d9588d..857dc09e930 100644 --- a/apps/kimi-code/AGENTS.md +++ b/apps/kimi-code/AGENTS.md @@ -65,6 +65,7 @@ The theme apply/switch mechanics live in the `write-tui` skill. The following ru ## General Coding Requirements +- The startup path before the workspace trust gate (`KimiTUI.start()` -> `maybeRunWorkspaceTrustPrompt()`) must not spawn child processes by bare command name — on Windows, cmd.exe / CreateProcess resolve them from the current directory first, so a binary planted in an untrusted workspace would run before the user confirms trust. When an external command is unavoidable, resolve it with `resolveCommandPath` from `src/utils/process/resolve-command.ts`, which returns an absolute PATH hit and refuses matches inside the cwd. - For optional object properties, pass `undefined` directly — do not use conditional spread. - Optional object properties do not need to additionally allow `undefined` in the type. - Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 8bb733f637c..7b651b83268 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,83 @@ # @moonshot-ai/kimi-code +## 0.36.0 + +### Minor Changes + +- [#2830](https://github.com/MoonshotAI/kimi-code/pull/2830) [`ec84a6f`](https://github.com/MoonshotAI/kimi-code/commit/ec84a6f9a3eb35e1118f8a327f7a11b3978a899c) Thanks [@liruifengv](https://github.com/liruifengv)! - Add an experimental fullscreen TUI mode. Set the `KIMI_CODE_TUI_FULL_SCREEN=1` environment variable to enable it. + +- [#2700](https://github.com/MoonshotAI/kimi-code/pull/2700) [`c9bfe8b`](https://github.com/MoonshotAI/kimi-code/commit/c9bfe8b2c8314ba4ef8806fb3b92ac654c1d1860) Thanks [@7Sageer](https://github.com/7Sageer)! - Add a configurable model pool for spawned subagents behind the `secondary-model` experiment (`KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master flag): with the experiment on, the `/secondary-model` command or the `[secondary_model]` section in config.toml sets a default model or a small named pool that the main agent picks from per spawn. A lone legacy `model` key in the same section keeps working as the fallback default. + +### Patch Changes + +- [#2830](https://github.com/MoonshotAI/kimi-code/pull/2830) [`ec84a6f`](https://github.com/MoonshotAI/kimi-code/commit/ec84a6f9a3eb35e1118f8a327f7a11b3978a899c) Thanks [@liruifengv](https://github.com/liruifengv)! - Render LaTeX math formulas (`$…$` / `$$…$$`) in messages as Unicode formulas. + +- [#2855](https://github.com/MoonshotAI/kimi-code/pull/2855) [`30f56a2`](https://github.com/MoonshotAI/kimi-code/commit/30f56a2d2da332cbf0c36a13cbe01aac5d319c7b) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix Ctrl+C being ignored during automatic retries of failed API requests. + +- [#2819](https://github.com/MoonshotAI/kimi-code/pull/2819) [`fe3cdae`](https://github.com/MoonshotAI/kimi-code/commit/fe3cdae5f8ab40be71b65eff32319eb94a53c17d) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix sessions failing with a provider 400 error on every follow-up request after a turn is interrupted while the model is still thinking, on strict OpenAI-compatible providers. + +- [#2847](https://github.com/MoonshotAI/kimi-code/pull/2847) [`3b0936d`](https://github.com/MoonshotAI/kimi-code/commit/3b0936d8e025c5a944759c40593d5f21bfb3e621) Thanks [@sailist](https://github.com/sailist)! - Fix plain Markdown files (such as CHANGELOG.md) in an installed plugin's root directory being misidentified as skills when the plugin relies on the root SKILL.md fallback. + +- [#2843](https://github.com/MoonshotAI/kimi-code/pull/2843) [`c212ae9`](https://github.com/MoonshotAI/kimi-code/commit/c212ae9715371c0d7939c15e664acbe0d7cf7fc3) Thanks [@sailist](https://github.com/sailist)! - Show project MCP launch targets in the workspace trust prompt, default to declining trust, and resolve fd and stty binaries to absolute paths so untrusted workspaces cannot plant bare-name executables before confirmation. + + `@moonshot-ai/kimi-code-sdk` contract change: `WorkspaceTrustInfo.gatedMcpServers` now carries structured `WorkspaceTrustMcpServerInfo` records (`name`, `transport`, and `command`/`args`/`cwd` or `url`) instead of plain strings, so SDK consumers rendering a trust prompt can show the full launch target. + +- [#2856](https://github.com/MoonshotAI/kimi-code/pull/2856) [`504e629`](https://github.com/MoonshotAI/kimi-code/commit/504e6292ede448367d1341751f9f98b24cc2994f) Thanks [@pvzheroes125](https://github.com/pvzheroes125)! - Refresh active MCP connections after OAuth credentials are added or reset. + +## 0.35.0 + +### Minor Changes + +- [#2816](https://github.com/MoonshotAI/kimi-code/pull/2816) [`ad12ad8`](https://github.com/MoonshotAI/kimi-code/commit/ad12ad8a140d24051d93ec98a4a6921ab33723ff) Thanks [@liruifengv](https://github.com/liruifengv)! - Show the live work progress of background subagents in the `/tasks` panel. + +- [#2840](https://github.com/MoonshotAI/kimi-code/pull/2840) [`68ce3c7`](https://github.com/MoonshotAI/kimi-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Image and video tool results now open in a fullscreen preview on click, with zoom support for images. + +### Patch Changes + +- [#2731](https://github.com/MoonshotAI/kimi-code/pull/2731) [`437a1b8`](https://github.com/MoonshotAI/kimi-code/commit/437a1b8ba1b7e0f6662bdadc669564fdc58c3f5a) Thanks [@pvzheroes125](https://github.com/pvzheroes125)! - Detect MCP servers that require OAuth without needing `auth: "oauth"` in the config. + +- [#2840](https://github.com/MoonshotAI/kimi-code/pull/2840) [`68ce3c7`](https://github.com/MoonshotAI/kimi-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Reduce UI stutter while AI responses stream in long sessions. + +- [#2699](https://github.com/MoonshotAI/kimi-code/pull/2699) [`c0b61c6`](https://github.com/MoonshotAI/kimi-code/commit/c0b61c6e558521fd003de786cad150a3aeb01667) Thanks [@sailist](https://github.com/sailist)! - Fix the token counts reported after compaction reading far below the real context size; they now match the numbers shown while the session runs. + +- [#2810](https://github.com/MoonshotAI/kimi-code/pull/2810) [`64abebc`](https://github.com/MoonshotAI/kimi-code/commit/64abebc95a13b066fefc4f96b062824ea5ec996b) Thanks [@huangzheng2016](https://github.com/huangzheng2016)! - Fix multi-select "Other" options so they can be deselected after being committed. + +- [#2701](https://github.com/MoonshotAI/kimi-code/pull/2701) [`7cd6476`](https://github.com/MoonshotAI/kimi-code/commit/7cd64766c8eeff30f3de4bd6467870555d9440db) Thanks [@sailist](https://github.com/sailist)! - Fix multi-second freezes at startup or while idle when a large search index loads, replays, or rebuilds. + +- [#2814](https://github.com/MoonshotAI/kimi-code/pull/2814) [`158c81d`](https://github.com/MoonshotAI/kimi-code/commit/158c81d7055587d582ca424f9b913426fca42559) Thanks [@huangzheng2016](https://github.com/huangzheng2016)! - Show a clear error message on Windows when Git for Windows is not installed, instead of exiting silently. + +- [#2838](https://github.com/MoonshotAI/kimi-code/pull/2838) [`e5be391`](https://github.com/MoonshotAI/kimi-code/commit/e5be39164b1b47d0b721aad49c41fdf4ec61a7c5) Thanks [@sailist](https://github.com/sailist)! - Close a Windows binary-planting gap in the footer git status: the git and gh commands used for the branch/dirty badge are now resolved to an absolute PATH location, so an executable planted in an untrusted workspace can no longer run before the workspace trust prompt. + +- [#2840](https://github.com/MoonshotAI/kimi-code/pull/2840) [`68ce3c7`](https://github.com/MoonshotAI/kimi-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add hover tooltips to icon-only buttons. + +- [#2840](https://github.com/MoonshotAI/kimi-code/pull/2840) [`68ce3c7`](https://github.com/MoonshotAI/kimi-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add breathing room around the fullscreen image preview so images no longer touch the screen edges. + +- [#2740](https://github.com/MoonshotAI/kimi-code/pull/2740) [`01c74e9`](https://github.com/MoonshotAI/kimi-code/commit/01c74e9372fcbbbe99614e859b53b505ed1664a8) Thanks [@oocz](https://github.com/oocz)! - Fix subagent tool changes in one session leaking into builtin profiles in later sessions. + +- [#2840](https://github.com/MoonshotAI/kimi-code/pull/2840) [`68ce3c7`](https://github.com/MoonshotAI/kimi-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Unify the fullscreen image and video previews with a shared circular close button and the same background overlay. + +- [#2826](https://github.com/MoonshotAI/kimi-code/pull/2826) [`3c9e3b2`](https://github.com/MoonshotAI/kimi-code/commit/3c9e3b297cf5286c761159c1b4d642c478fd394d) Thanks [@liruifengv](https://github.com/liruifengv)! - Page the /sessions picker list so it opens fast with large session counts. + +- [#2840](https://github.com/MoonshotAI/kimi-code/pull/2840) [`68ce3c7`](https://github.com/MoonshotAI/kimi-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Widen the sidebar's minimum draggable width. + +- [#2723](https://github.com/MoonshotAI/kimi-code/pull/2723) [`e702817`](https://github.com/MoonshotAI/kimi-code/commit/e7028171244789aff58f93da80d477ce3afc939a) Thanks [@sailist](https://github.com/sailist)! - Fix a spurious "Failed to steer" error when sending a message while a goal run is between turns. + +- [#2840](https://github.com/MoonshotAI/kimi-code/pull/2840) [`68ce3c7`](https://github.com/MoonshotAI/kimi-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Reduce memory and CPU usage when the app stays open for a long time. + +- [#2825](https://github.com/MoonshotAI/kimi-code/pull/2825) [`df8ce73`](https://github.com/MoonshotAI/kimi-code/commit/df8ce73e45e3c473cb58e69311c1213e327f0c01) Thanks [@liruifengv](https://github.com/liruifengv)! - Show retry progress in the loading indicator when a model request fails and is retried, with the attempt count and a detail line for the provider error. + +- [#2840](https://github.com/MoonshotAI/kimi-code/pull/2840) [`68ce3c7`](https://github.com/MoonshotAI/kimi-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Reduce memory usage and stutter during long sessions. + +- [#2837](https://github.com/MoonshotAI/kimi-code/pull/2837) [`101c4d1`](https://github.com/MoonshotAI/kimi-code/commit/101c4d199746bf2ed4f26375b65a6fcb6cba2a60) Thanks [@sailist](https://github.com/sailist)! - Remove the Agent and AgentSwarm tools from the built-in coder subagent profile, so coder subagents no longer delegate further by default. Custom profiles that list these tools explicitly can still opt in. + +- [#2695](https://github.com/MoonshotAI/kimi-code/pull/2695) [`71ff2a0`](https://github.com/MoonshotAI/kimi-code/commit/71ff2a0fffb2ebf399194436ef2d4b599c9988ad) Thanks [@sailist](https://github.com/sailist)! - Fix a Windows security risk where commands launched before the workspace trust prompt could run a malicious executable placed in the current folder. + +- [#2813](https://github.com/MoonshotAI/kimi-code/pull/2813) [`619564d`](https://github.com/MoonshotAI/kimi-code/commit/619564dcf9ee10a3cfbf7ecbc764c6b9b63fc91b) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix the web UI repeatedly losing its realtime connection every ~30 seconds when the server runs behind a reverse proxy or gateway with an idle connection timeout; the server now sends a WebSocket heartbeat and only closes connections that stop responding entirely. + +- [#2842](https://github.com/MoonshotAI/kimi-code/pull/2842) [`e476c5a`](https://github.com/MoonshotAI/kimi-code/commit/e476c5a8bbe68fb0b6eb0096aa1efcb893b1a8fc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add the Modern Web Guidance plugin to the bundled plugin marketplace. Run /plugins and select Modern Web Guidance to install it. + +- Thanks [@Leakless](https://github.com/Leakless) and [@winmin](https://github.com/winmin) for reporting the Windows binary-planting issues fixed in this release. + ## 0.34.0 ### Minor Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index d363c2f9d24..79016f4318e 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@mbuckaway/kimi-code", - "version": "0.34.0-MB.1.13", + "version": "0.36.0-MB.1.13", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 3d6c741cebd..ceade4c8135 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -1,4 +1,4 @@ -import { execSync, spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -29,6 +29,7 @@ import { startupTrace } from '#/utils/startup-trace'; import { currentTheme, getColorPalette } from '#/tui/theme'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; import type { CLIOptions } from './options'; import { resolveAgentProfileSelection } from './agent-selection'; @@ -155,23 +156,34 @@ export async function runShell( }; let savedStty: string | undefined; - try { - // stty operates on the terminal behind stdin, so stdin must be the TTY — - // piping /dev/null (ignore) makes stty fail with "not a tty". - const saved = execSync('stty -g', { - encoding: 'utf8', - stdio: ['inherit', 'pipe', 'ignore'], - }); - savedStty = typeof saved === 'string' ? saved.trim() : undefined; - execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); - } catch { - /* ignore */ + // stty runs before tui.start() reaches the workspace trust gate, so it must + // never be resolved by name through PATH: a `.` or empty PATH segment would + // let an untrusted checkout plant an `stty` executable and run it pre-trust. + // resolveCommandPath returns an absolute path and refuses hits inside the + // cwd; when it cannot resolve stty, skip the save/restore entirely — it is + // best-effort terminal hygiene, not required for startup. + // stty is also POSIX-only, so skip it on Windows instead of relying on the + // catch below. + const sttyPath = process.platform === 'win32' ? undefined : resolveCommandPath('stty'); + if (sttyPath !== undefined) { + try { + // stty operates on the terminal behind stdin, so stdin must be the TTY — + // piping /dev/null (ignore) makes stty fail with "not a tty". + const saved = execFileSync(sttyPath, ['-g'], { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'ignore'], + }); + savedStty = saved.trim(); + execFileSync(sttyPath, ['-ixon'], { stdio: ['inherit', 'ignore', 'ignore'] }); + } catch { + /* ignore */ + } } const restoreStty = (): void => { - if (savedStty === undefined) return; + if (sttyPath === undefined || savedStty === undefined) return; const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0); if (args.length === 0) return; - spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] }); + spawnSync(sttyPath, args, { stdio: ['inherit', 'ignore', 'ignore'] }); }; // If we crash without going through KimiTUI.stop(), the terminal is left in @@ -219,7 +231,7 @@ export async function runShell( const sessionId = tui.getCurrentSessionId(); const hasContent = tui.hasSessionContent(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); const gutter = ' '.repeat(CHROME_GUTTER); process.stdout.write(`${gutter}Bye!\n`); @@ -257,11 +269,12 @@ export async function runShell( config_ms: configMs, init_ms: initMs, mcp_ms: mcpMs, + tui_mode: tui.state.ui.mode, }); } catch (error) { removeCrashHandlers(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); await harness.close(); throw error; diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index bc026b1266d..2c38dc7c25f 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -9,6 +9,7 @@ import { NATIVE_UPDATE_INSTRUCTION_WIN, } from '#/constant/app'; import { loadTuiConfig } from '#/tui/config'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; import { readUpdateCache } from './cache'; import { tryAcquireUpdateInstallLock } from './install-lock'; @@ -142,6 +143,21 @@ function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * Resolve a spawn target from `spawnForSource` to an absolute executable path + * via PATH, refusing hits inside the current working directory: the update + * preflight runs before the workspace trust gate, so a package-manager binary + * planted in an untrusted workspace must never be executed. On win32 the + * resolved path is quoted because the spawn goes through cmd.exe (shell: + * true) and paths like `C:\Program Files\...` would otherwise split. Returns + * undefined when the command cannot be safely resolved. + */ +function resolveSpawnCommand(cmd: string, platform: NodeJS.Platform): string | undefined { + const resolved = resolveCommandPath(cmd); + if (resolved === undefined) return undefined; + return platform === 'win32' ? `"${resolved}"` : resolved; +} + const THIRD_PARTY_SOURCE_NOTE = '\nNote: Third-party sources may lag behind the official release.\n' + `For the latest updates, use the official installer: ${KIMI_CODE_OFFICIAL_INSTALL_URL}\n`; @@ -500,12 +516,16 @@ export async function installUpdate( platform: NodeJS.Platform, ): Promise { const { cmd, args } = spawnForSource(source, version, platform); + const resolvedCmd = resolveSpawnCommand(cmd, platform); + if (resolvedCmd === undefined) { + throw new Error(`${cmd} was not found in PATH; cannot install the update`); + } await new Promise((resolve, reject) => { // Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the // CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without // a shell, so run through the shell on win32. The version is a validated // semver and the package name is a constant, so args are shell-safe. - const child = spawn(cmd, [...args], { + const child = spawn(resolvedCmd, [...args], { stdio: 'inherit', shell: platform === 'win32' ? true : undefined, }); @@ -616,7 +636,15 @@ async function startBackgroundInstall( }); }; - const child = spawn(cmd, [...args], { + const resolvedCmd = resolveSpawnCommand(cmd, platform); + if (resolvedCmd === undefined) { + // The package manager cannot be resolved to an absolute path outside + // the cwd — record a normal install failure instead of spawning a bare + // command name that Windows would resolve into the untrusted workspace. + finish(false); + return; + } + const child = spawn(resolvedCmd, [...args], { detached: true, stdio: 'ignore', shell: platform === 'win32' ? true : undefined, diff --git a/apps/kimi-code/src/cli/update/source.ts b/apps/kimi-code/src/cli/update/source.ts index 7d6904b673b..464e3231864 100644 --- a/apps/kimi-code/src/cli/update/source.ts +++ b/apps/kimi-code/src/cli/update/source.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'; import { join, resolve } from 'node:path'; import { getHostPackageRoot } from '#/cli/version'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; import { NPM_PACKAGE_NAME, type InstallSource } from './types'; @@ -76,6 +77,17 @@ function npmCommand(platform: NodeJS.Platform): string { return platform === 'win32' ? 'npm.cmd' : 'npm'; } +// The install-source detection runs before the workspace trust gate, so the +// npm binary must be resolved through PATH to an absolute path — a bare name +// would let cmd.exe pick up an `npm.cmd` planted in the current directory. +function npmGlobalPrefix(platform: NodeJS.Platform): Promise { + const resolved = resolveCommandPath(npmCommand(platform)); + if (resolved === undefined) { + return Promise.reject(new Error('npm was not found in PATH')); + } + return execFileText(resolved, ['prefix', '-g']).then((text) => text.trim()); +} + function execFileText(command: string, args: readonly string[]): Promise { return new Promise((resolveOutput, reject) => { execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => { @@ -140,7 +152,7 @@ export async function detectInstallSource( getPackageRoot: deps.getPackageRoot ?? getHostPackageRoot, getGlobalPrefix: deps.getGlobalPrefix ?? - (() => execFileText(npmCommand(platform), ['prefix', '-g']).then((text) => text.trim())), + (() => npmGlobalPrefix(platform)), detectNative: deps.detectNative ?? detectNativeInstall, platform, }; diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index a3a0f9999db..18f7edb5d8e 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,8 +1,8 @@ import { effectiveModelAlias, + PRIMARY_SUBAGENT_MODEL_CHOICE, SECONDARY_DERIVED_MODEL_ALIAS, type ExperimentalFeatureState, - type KimiConfig, type ModelAlias, type PermissionMode, type Session, @@ -57,6 +57,7 @@ export function currentTuiConfig(host: Pick): TuiConf theme: host.state.appState.theme, editorCommand: host.state.appState.editorCommand, disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true, cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, @@ -269,6 +270,15 @@ export async function handleSecondaryModelCommand(host: SlashCommandHost, args: const alias = args.trim(); await refreshModelsForPicker(host); const models = pickerModelsForHost(host); + // The pool reserves `primary` as the symbolic "caller's own model" choice — + // a user alias with that name can never be the subagent default. + delete models[PRIMARY_SUBAGENT_MODEL_CHOICE]; + if (alias === PRIMARY_SUBAGENT_MODEL_CHOICE) { + host.showError( + `"${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved by the subagent model pool (it always binds the caller's own model) — rename the [models] alias to use it here.`, + ); + return; + } if (Object.keys(models).length === 0) { host.showNotice( 'No models configured', @@ -281,7 +291,10 @@ export async function handleSecondaryModelCommand(host: SlashCommandHost, args: return; } const secondary = (await host.harness.getConfig()).secondaryModel; - showSecondaryModelPicker(host, models, secondary?.model ?? '', secondary?.defaultEffort, alias); + // The v2 engine honors a lone legacy `model` key as the fallback pool + // default — reflect it as the picker's current value. + const current = secondary?.defaultModel ?? secondary?.model ?? ''; + showSecondaryModelPicker(host, models, current, alias.length > 0 ? alias : undefined); } export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise { @@ -427,8 +440,8 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise /** * The models a picker may offer: the user's configured aliases with * host-effective provider resolution applied, minus the synthesized - * `__secondary__` derived entry — a runtime artifact of the `[secondary_model]` - * recipe that must never be selectable as a primary or secondary model. + * `__secondary__` derived entry — a runtime artifact of the v1 engine's + * `[secondary_model]` recipe that must never be selectable as a model. */ function pickerModelsForHost(host: SlashCommandHost): Record { return Object.fromEntries( @@ -604,14 +617,13 @@ async function persistModelSelection( } // --------------------------------------------------------------------------- -// Secondary model (`/secondary_model`) +// Secondary model (`/secondary-model`) — persists `[secondary_model] default_model` // --------------------------------------------------------------------------- function showSecondaryModelPicker( host: SlashCommandHost, models: Record, currentValue: string, - currentEffort: string | undefined, selectedValue?: string, ): void { host.mountEditorReplacement( @@ -619,11 +631,14 @@ function showSecondaryModelPicker( models, currentValue, selectedValue, - currentThinkingEffort: currentEffort ?? 'off', + currentThinkingEffort: 'off', + // Subagent pool bindings carry no explicit thinking level, so the picker + // hides the Thinking footer instead of offering a no-op choice. + thinkingControl: false, title: ' Select a secondary model (subagents)', - onSelect: ({ alias, thinking }) => { + onSelect: ({ alias }) => { host.restoreEditor(); - void performSecondaryModelSwitch(host, alias, thinking); + void performSecondaryModelSave(host, alias); }, onCancel: () => { host.restoreEditor(); @@ -633,65 +648,32 @@ function showSecondaryModelPicker( } /** - * Persist-first, then live-apply: the synthesized derived entry only exists in - * the core config after a reload. No session-only variant — a session-local - * recipe with patch fields would bind a derived alias the core config cannot - * resolve. + * Persists `[secondary_model] default_model`. When a + * `[secondary_model.models]` pool exists and does not list the alias yet, the + * alias is added with an empty description — the engine requires the default + * to be a pool key. Without a pool the default alone forms an implicit + * single-entry pool, so nothing else is written. No live-apply step: the + * engine resolves the pool per spawn, so the next subagent dispatch picks the + * new value up on its own. */ -async function performSecondaryModelSwitch( - host: SlashCommandHost, - alias: string, - effort: ThinkingEffort, -): Promise { +async function performSecondaryModelSave(host: SlashCommandHost, alias: string): Promise { const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); - let updatedConfig: KimiConfig; try { - updatedConfig = await host.harness.setConfig({ - secondaryModel: { model: alias, defaultEffort: effort }, - }); + const config = await host.harness.getConfig({ reload: true }); + const existing = config.secondaryModel?.models; + const patch: { defaultModel: string; models?: Record } = { + defaultModel: alias, + }; + if (existing !== undefined) { + patch.models = { ...existing, [alias]: existing[alias] ?? '' }; + } + await host.harness.setConfig({ secondaryModel: patch }); } catch (error) { host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`); return; } - if (host.session !== undefined) { - try { - await host.session.applyPersistedSecondaryModel(); - } catch (error) { - host.showError( - `Saved ${displayName} as the secondary model, but failed to apply it to this session: ${formatErrorMessage(error)}`, - ); - return; - } - } - host.setAppState({ availableModels: updatedConfig.models ?? {} }); - // Report the effective binding from the reloaded config, not the picked - // value: KIMI_SECONDARY_MODEL / KIMI_SECONDARY_EFFORT override the recipe at - // runtime, and the session binds the overlaid snapshot (mirrors how - // /model displays the effective alias read back from the session). - const effective = updatedConfig.secondaryModel; - const envOverrides: string[] = []; - if (effective?.model !== undefined && effective.model !== alias) { - envOverrides.push(`KIMI_SECONDARY_MODEL=${effective.model}`); - } - if (effective?.defaultEffort !== undefined && effective.defaultEffort !== effort) { - envOverrides.push(`KIMI_SECONDARY_EFFORT=${effective.defaultEffort}`); - } - if (envOverrides.length > 0 && effective?.model !== undefined) { - const effectiveName = modelDisplayName( - effective.model, - updatedConfig.models?.[effective.model], - ); - host.showStatus( - `Saved ${displayName} as the secondary model, but ${envOverrides.join(' and ')} ` + - `overrides it at runtime — subagents bind ${effectiveName} until the env var is unset.`, - 'warning', - ); - return; - } host.showStatus( - host.session === undefined - ? `Secondary model set to ${displayName} with thinking ${effort}; applies to new sessions.` - : `Secondary model set to ${displayName} with thinking ${effort}.`, + `Secondary model set to ${displayName}. Newly spawned subagents will use it by default.`, 'success', ); } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index ad064bebabe..706c1ab4cba 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -443,7 +443,7 @@ async function handleBuiltInSlashCommand( case 'model': await handleModelCommand(host, args); return; - case 'secondary_model': + case 'secondary-model': await handleSecondaryModelCommand(host, args); return; case 'effort': diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 9244c7d2527..f27652b8674 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -29,6 +29,7 @@ import { import { UsagePanelComponent } from '../components/messages/usage-panel'; import { createMarkdownTheme } from '../theme/pi-tui-theme'; import { formatErrorMessage } from '../utils/event-payload'; +import { createMarkdownOptions } from '../utils/markdown-options'; import { formatPluginSourceLabel, isOfficialPluginInstall, @@ -566,7 +567,7 @@ async function installCapabilityFromPanel( host.showNotice(`${label} is installed.`); host.state.transcriptContainer.addChild(new Spacer(1)); host.state.transcriptContainer.addChild( - new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme()), + new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme(), undefined, createMarkdownOptions()), ); host.state.ui.requestRender(); return; diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index dbfbddfcb2b..61ec07b9119 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -6,10 +6,12 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, + cascadeSubagentModelPool, catalogProviderModels, CatalogFetchError, DEFAULT_CATALOG_URL, resolveCatalogImport, + SECONDARY_DERIVED_MODEL_ALIAS, type Catalog, type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; @@ -231,6 +233,10 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { // entered. The model selector that follows is just a convenience to pick the // default model; ESC leaves the provider in place without a default selection. const existingConfig = await host.harness.getConfig(); + const poolSnapshot = + existingConfig.providers[providerId] !== undefined + ? existingConfig.secondaryModel + : undefined; if (existingConfig.providers[providerId] !== undefined) { await host.harness.removeProvider(providerId); } @@ -251,6 +257,16 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { models: config.models, }); + // removeProvider cascaded the subagent pool against a model table where + // every `${providerId}/...` alias was absent; restore the entries that + // survived the re-add (aliases the catalog genuinely dropped stay dropped). + if (poolSnapshot !== undefined) { + const restored = cascadeSubagentModelPool(poolSnapshot, config.models ?? {}); + if (restored !== null) { + await host.harness.setConfig({ secondaryModel: restored ?? poolSnapshot }); + } + } + await host.authFlow.refreshConfigAfterLogin(); host.track('connect', { provider: providerId, method: 'catalog' }); host.showStatus(`Provider added: ${entry.name ?? providerId}`); @@ -263,8 +279,11 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { // Build a merged model dictionary that includes existing models plus the // newly-persisted provider's models, so the tabbed selector shows every // provider's tab (the new provider's tab starts active via initialTabId). + // The v1 runtime may carry the synthesized `__secondary__` derived entry — + // never selectable in a picker. const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); const mergedModels = { ...stateModels }; + delete mergedModels[SECONDARY_DERIVED_MODEL_ALIAS]; const selector = new TabbedModelSelectorComponent({ models: mergedModels, @@ -356,8 +375,10 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise ); // Offer the model selector so the user can pick a default, just like the - // catalog (known-provider) flow. - const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); + // catalog (known-provider) flow. Copy without the v1-synthesized + // `__secondary__` derived entry — never selectable in a picker. + const stateModels = { ...(await host.harness.getConfig().then((c) => c.models ?? {})) }; + delete stateModels[SECONDARY_DERIVED_MODEL_ALIAS]; const firstNewAlias = Object.keys(stateModels).find((a) => addedProviderIds.some((pid) => a.startsWith(`${pid}/`)), ); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index fd8ace8e35f..4d2120a6422 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -193,8 +193,8 @@ export const BUILTIN_SLASH_COMMANDS = [ availability: 'always', }, { - name: 'secondary_model', - aliases: [], + name: 'secondary-model', + aliases: ['subagent-model'], description: 'Configure the secondary model for subagents', priority: 90, availability: 'always', diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 482b852ff3f..041ec2d246a 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -2,6 +2,7 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; import { currentTheme, lightColors } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; +import { setMarkdownRenderLatex } from '../utils/markdown-options'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; @@ -55,6 +56,10 @@ export async function applyReloadedTuiConfig( host: SlashCommandHost, config: TuiConfig, ): Promise { + // Set the LaTeX toggle before applyTheme: theme application invalidates the + // transcript components, which rebuild their Markdown children and copy the + // options at construction — so the new value must be live by then. + setMarkdownRenderLatex(config.renderLatex ?? true); const resolved = config.theme === 'auto' ? (currentTheme.palette === lightColors ? 'light' : 'dark') : undefined; @@ -63,6 +68,7 @@ export async function applyReloadedTuiConfig( host.setAppState({ editorCommand: config.editorCommand, disablePasteBurst: config.disablePasteBurst, + renderLatex: config.renderLatex, cacheExpiryHint: config.cacheExpiryHint, notifications: config.notifications, upgrade: config.upgrade, diff --git a/apps/kimi-code/src/tui/components/chrome/banner.ts b/apps/kimi-code/src/tui/components/chrome/banner.ts index 58b6faa5838..1ecf4af2c28 100644 --- a/apps/kimi-code/src/tui/components/chrome/banner.ts +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -6,6 +6,14 @@ import type { BannerState } from '#/tui/types'; const PREFIX_STAR = '✦'; const PADDING = ' '; +/** + * Minimum column count the main text gets next to an inline tag. A long tag + * (e.g. a full sentence from the remote banner config) can fit on the line + * yet leave only a sliver for the main text, which then wraps into a narrow, + * hard-broken column. When that would happen the tag moves onto its own line + * and the main text uses (nearly) the full width instead. + */ +const MIN_INLINE_MAIN_TEXT_WIDTH = 16; export class BannerComponent implements Component { constructor(private readonly state: BannerState) {} @@ -30,14 +38,22 @@ export class BannerComponent implements Component { const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : ''; const tagWidth = visibleWidth(tagDisplay); const showTag = tagWidth > 0 && tagWidth < width; + // Hanging indent aligning with the tag text (right after "✦ "). + const hangingWidth = visibleWidth(PREFIX_STAR + PADDING); + // If the inline tag would squeeze the main text into too narrow a column, + // render the tag on its own line and give the main text the full width. + const tagOnOwnLine = showTag && width - tagWidth < MIN_INLINE_MAIN_TEXT_WIDTH; + const inlineTag = showTag && !tagOnOwnLine; // Body lines (continuations of the main text) indent to match the first - // line's main-text column, which starts right after the tag display. - const bodyIndent = showTag ? ' '.repeat(tagWidth) : ''; + // line's main-text column, which starts right after the tag display. When + // the tag is on its own line, the main text aligns with the tag text. + const bodyIndent = inlineTag ? ' '.repeat(tagWidth) : tagOnOwnLine ? ' '.repeat(hangingWidth) : ''; // Descriptive subtext lines (the second line in the design) start at the // column after the leading star + space, aligning with the tag text itself. - const descIndent = showTag ? ' '.repeat(visibleWidth(PREFIX_STAR + PADDING)) : ''; - const bodyContentWidth = width - (showTag ? tagWidth : 0); - const descContentWidth = width - (showTag ? visibleWidth(PREFIX_STAR + PADDING) : 0); + const descIndent = showTag ? ' '.repeat(hangingWidth) : ''; + const bodyContentWidth = + width - (inlineTag ? tagWidth : tagOnOwnLine ? hangingWidth : 0); + const descContentWidth = width - (showTag ? hangingWidth : 0); if (bodyContentWidth <= 0) { return ['']; @@ -47,11 +63,14 @@ export class BannerComponent implements Component { const subSegments = this.state.subText ? this.state.subText.split('\n') : []; const result: string[] = []; + if (tagOnOwnLine) { + result.push(tagStyled); + } for (let i = 0; i < mainSegments.length; i++) { const wrapped = wrapTextWithAnsi(mainSegments[i]!, bodyContentWidth); for (let j = 0; j < wrapped.length; j++) { const boldLine = main(wrapped[j]!); - if (i === 0 && j === 0 && showTag) { + if (i === 0 && j === 0 && inlineTag) { result.push(tagDisplay + boldLine); } else { result.push(bodyIndent + boldLine); diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index ed19793af55..43e076ec01f 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -18,6 +18,7 @@ import { Container } from '@moonshot-ai/pi-tui'; import type { Component } from '@moonshot-ai/pi-tui'; +import { prefixPreservingOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; interface TranscriptRenderCache { @@ -68,7 +69,9 @@ export class GutterContainer extends Container { prefixed.push(cache.prefixed[i]!); } else { allReused = false; - prefixed.push(lines.map((line) => lead + line)); + // OSC 133 zone markers must stay at byte 0 for the fullscreen + // renderer's prompt navigation, so the gutter goes after them. + prefixed.push(lines.map((line) => prefixPreservingOsc133Zone(line, lead))); } i++; } diff --git a/apps/kimi-code/src/tui/components/dialogs/agent-activity-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/agent-activity-viewer.ts new file mode 100644 index 00000000000..6a58d9ffd5b --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/agent-activity-viewer.ts @@ -0,0 +1,434 @@ +/** + * AgentActivityViewer — full-screen detail view for a background agent task. + * + * Same full-screen skeleton as `TaskOutputViewer` (header / scrolling body / + * footer, tail-follow), but the body is assembled from the in-memory + * `SubagentActivityRecord` instead of the task's captured output: recent + * steps with their assistant text (Markdown, same as the main transcript) + * and tool calls rendered through the main-flow result renderers + * (`pickResultRenderer` / `pickChip` / `extractKeyArgument`). `ToolCallComponent` + * itself is not reused — it is a live, event-driven component, while this + * view renders a snapshot. + * + * Ctrl+O toggles a global expand of every tool result (same semantics as the + * main transcript's `toolOutputExpanded`), capped by what the store retained. + */ + +import { + Container, + Key, + matchesKey, + type Focusable, + type Terminal, + truncateToWidth, + visibleWidth, +} from '@moonshot-ai/pi-tui'; +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { + SubagentActivityRecord, + SubToolCallActivity, +} from '#/tui/controllers/subagent-activity-store'; +import { currentTheme } from '#/tui/theme'; +import type { ToolCallBlockData } from '#/tui/types'; +import { printableChar } from '#/tui/utils/printable-key'; +import { AssistantMessageComponent } from '../messages/assistant-message'; +import { extractKeyArgument } from '../messages/tool-call'; +import { pickChip } from '../messages/tool-renderers/chip'; +import { pickResultRenderer } from '../messages/tool-renderers/registry'; +import { STATUS_LABEL, statusColor } from './task-output-viewer'; + +const ELLIPSIS = '…'; + +export interface AgentActivityViewerProps { + readonly taskId: string; + readonly info: BackgroundTaskInfo | undefined; + readonly record: SubagentActivityRecord | undefined; + readonly onClose: () => void; +} + +function padToWidth(line: string, width: number): string { + const w = visibleWidth(line); + if (w === width) return line; + if (w > width) return truncateToWidth(line, width, ELLIPSIS); + return line + ' '.repeat(width - w); +} + +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + return padToWidth(s, width); +} + +export class AgentActivityViewer extends Container implements Focusable { + focused = false; + + private props: AgentActivityViewerProps; + private readonly terminal: Terminal; + private expanded = false; + /** Index of the topmost visible body line. */ + private scrollTop = 0; + /** Stick to the bottom on updates until the user scrolls away. */ + private followTail = true; + private lines: string[] = []; + private lastCacheKey = ''; + + constructor(props: AgentActivityViewerProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + } + + setProps(next: AgentActivityViewerProps): void { + this.props = next; + this.invalidate(); + } + + override invalidate(): void { + // Theme switches arrive as a tree-wide invalidate; the styled body lines + // are cached, so drop the cache here to pick up the new palette. + this.lastCacheKey = ''; + super.invalidate(); + } + + // ── input ────────────────────────────────────────────────────────── + + handleInput(data: string): void { + const visible = this.viewableRows(); + const k = printableChar(data); + + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onClose(); + return; + } + if (matchesKey(data, Key.ctrl('o'))) { + this.expanded = !this.expanded; + this.lastCacheKey = ''; + this.invalidate(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + this.scrollBy(-1); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + this.scrollBy(1); + return; + } + if ( + matchesKey(data, Key.pageUp) || + matchesKey(data, Key.ctrl('u')) || + k === ' ' || + data === '\u0002' /* C-b */ + ) { + this.scrollBy(-Math.max(1, visible - 1)); + return; + } + if ( + matchesKey(data, Key.pageDown) || + matchesKey(data, Key.ctrl('d')) || + data === '\u0006' /* C-f */ + ) { + this.scrollBy(Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.home) || k === 'g') { + this.scrollTo(0); + return; + } + if (matchesKey(data, Key.end) || k === 'G') { + this.scrollTo(this.maxScroll()); + return; + } + } + + private scrollBy(delta: number): void { + this.scrollTo(this.scrollTop + delta); + } + + private scrollTo(target: number): void { + this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); + this.followTail = this.scrollTop >= this.maxScroll(); + this.invalidate(); + } + + private maxScroll(): number { + return Math.max(0, this.lines.length - this.viewableRows()); + } + + /** Content rows inside the body frame: total rows minus header(1) + + * footer(1) + top border(1) + bottom border(1). */ + private viewableRows(): number { + return Math.max(1, this.terminal.rows - 4); + } + + // ── body assembly ────────────────────────────────────────────────── + + private cacheKey(innerWidth: number): string { + const record = this.props.record; + return [ + String(innerWidth), + this.expanded ? 'x' : 'c', + record?.agentId ?? '', + String(record?.version ?? -1), + ].join('|'); + } + + private buildLines(innerWidth: number): string[] { + const record = this.props.record; + if (record === undefined) { + return [currentTheme.dim(`${MESSAGE_INDENT}[no activity recorded]`)]; + } + + const out: string[] = []; + for (const step of record.steps) { + out.push(currentTheme.dim(`── step ${String(step.step)} ──`)); + if (step.retrying !== undefined) { + out.push(currentTheme.fg('warning', `${MESSAGE_INDENT}↻ ${step.retrying}`)); + } + if (step.textTail.trim().length > 0) { + const message = new AssistantMessageComponent(); + message.updateContent(step.textTail); + out.push(...message.render(innerWidth)); + } + for (const call of step.toolCalls) { + out.push(this.buildToolCallHeader(call)); + out.push(...this.renderToolCallBody(call, innerWidth)); + } + out.push(''); + } + + if (record.error !== undefined && record.error.length > 0) { + out.push(currentTheme.fg('error', 'Failed')); + const message = new AssistantMessageComponent(); + message.updateContent(record.error); + out.push(...message.render(innerWidth)); + } else if (record.resultSummary !== undefined && record.resultSummary.length > 0) { + out.push(currentTheme.boldFg('primary', 'Result')); + const message = new AssistantMessageComponent(); + message.updateContent(record.resultSummary); + out.push(...message.render(innerWidth)); + } + + if (out.length === 0) { + out.push(currentTheme.dim(`${MESSAGE_INDENT}Waiting for activity…`)); + } + return out; + } + + /** Same shape as the main flow's generic header (`tool-call.ts` + * `buildHeader`): bullet + verb + name + key argument + chip. Custom + * per-tool label wording (e.g. "Ran a command") is intentionally not + * mirrored — the per-tool *body* renderers carry the specialization. */ + private buildToolCallHeader(call: SubToolCallActivity): string { + let bullet: string; + if (call.status === 'error') { + bullet = currentTheme.fg('error', '✗ '); + } else if (call.status === 'done') { + bullet = currentTheme.fg('success', STATUS_BULLET); + } else { + bullet = currentTheme.fg('text', STATUS_BULLET); + } + const verb = call.status === 'running' ? 'Using' : 'Used'; + const name = currentTheme.boldFg('primary', call.name); + const keyArg = extractKeyArgument(call.name, call.args); + const argStr = keyArg === null || keyArg.length === 0 ? '' : currentTheme.dim(` (${keyArg})`); + + let chipStr = ''; + if (call.result !== undefined) { + const provider = pickChip(call.name); + const text = provider?.(this.toToolCallBlockData(call), call.result) ?? ''; + if (text.length > 0) { + chipStr = + call.result.is_error === true + ? currentTheme.fg('error', ` · ${text}`) + : currentTheme.dim(` · ${text}`); + } + } + return `${bullet}${verb} ${name}${argStr}${chipStr}`; + } + + private renderToolCallBody(call: SubToolCallActivity, innerWidth: number): string[] { + if (call.result === undefined) { + return call.liveOutputTail === undefined || call.liveOutputTail.length === 0 + ? [] + : [currentTheme.dim(`${MESSAGE_INDENT}│ ${call.liveOutputTail}`)]; + } + // The store caps retained output, which cannot survive as a parseable + // media envelope (base64) — show a marker instead of dumping the blob. + if (call.name === 'ReadMediaFile' && call.result.is_error !== true) { + return [currentTheme.dim(`${MESSAGE_INDENT}[media output omitted]`)]; + } + const components = pickResultRenderer(call.name)( + this.toToolCallBlockData(call), + call.result, + { expanded: this.expanded }, + ); + const out: string[] = []; + for (const component of components) { + out.push(...component.render(innerWidth)); + } + return out; + } + + private toToolCallBlockData(call: SubToolCallActivity): ToolCallBlockData { + return { id: call.id, name: call.name, args: call.args }; + } + + // ── render ───────────────────────────────────────────────────────── + + override render(width: number): string[] { + const rows = Math.max(3, this.terminal.rows); + const bodyHeight = rows - 2; + const innerWidth = Math.max(1, width - 4); + + const key = this.cacheKey(innerWidth); + if (key !== this.lastCacheKey) { + this.lines = this.buildLines(innerWidth); + this.lastCacheKey = key; + } + if (this.followTail) this.scrollTop = this.maxScroll(); + + const header = this.renderHeader(width); + const body = this.renderBody(width, bodyHeight); + const footer = this.renderFooter(width, bodyHeight); + + const out: string[] = [header]; + for (const line of body) out.push(line); + out.push(footer); + return out; + } + + private renderHeader(width: number): string { + const title = currentTheme.boldFg('primary', ' Agent activity '); + const record = this.props.record; + const info = this.props.info; + const segments: string[] = []; + + if (record !== undefined) { + const label = + record.description !== undefined && record.description.length > 0 + ? `${record.agentName} › ${record.description}` + : record.agentName; + segments.push(currentTheme.boldFg('text', label)); + } else { + segments.push(currentTheme.boldFg('text', this.props.taskId)); + } + if (info !== undefined) { + segments.push(currentTheme.fg(statusColor(info.status), STATUS_LABEL[info.status])); + } + if (record !== undefined && record.steps.length > 0) { + const from = record.steps[0]!.step; + const to = record.steps.at(-1)!.step; + let range = `step ${String(from)}–${String(to)} / ${String(record.totalSteps)}`; + if (record.totalSteps > record.steps.length) range += ' · earlier steps discarded'; + segments.push(currentTheme.fg('textMuted', range)); + } + + const composed = title + segments.join(' '); + return fitExactly(composed, width); + } + + private renderBody(width: number, bodyHeight: number): string[] { + const innerWidth = Math.max(1, width - 4); + + const max = this.maxScroll(); + if (this.scrollTop > max) this.scrollTop = max; + if (this.scrollTop < 0) this.scrollTop = 0; + + const viewRows = Math.max(1, bodyHeight - 2); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + + const out: string[] = [top]; + for (let i = 0; i < viewRows; i++) { + const lineIndex = this.scrollTop + i; + const raw = this.lines[lineIndex] ?? ''; + const inner = fitExactly(raw, innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); + } + out.push(bottom); + return out; + } + + private renderFooter(width: number, bodyHeight: number): string { + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); + + const total = this.lines.length; + const viewRows = Math.max(1, bodyHeight - 2); + const maxScroll = Math.max(0, total - viewRows); + const percent = + maxScroll === 0 ? 100 : Math.round((this.scrollTop / maxScroll) * 100); + const lineFrom = total === 0 ? 0 : this.scrollTop + 1; + const lineTo = Math.min(total, this.scrollTop + viewRows); + + const position = currentTheme.fg( + 'textMuted', + ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, + ); + const keys = + `${key('↑↓')} ${dim('line')} ` + + `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('g/G')} ${dim('top/bot')} ` + + `${key('Ctrl+O')} ${dim(this.expanded ? 'collapse' : 'expand')} ` + + `${key('Q/Esc')} ${dim('cancel')}`; + const left = ` ${keys}`; + const leftW = visibleWidth(left); + const rightW = visibleWidth(position); + if (leftW + 2 + rightW <= width) { + return left + ' '.repeat(width - leftW - rightW) + position; + } + return fitExactly(left, width); + } +} + +/** + * Plain-text preview of a record for the tasks browser's Preview frame (the + * frame styles whole lines itself, so this stays ANSI-free). The frame shows + * the tail of the string, so the full retained activity is returned. + */ +export function formatSubagentActivityPreview(record: SubagentActivityRecord): string { + const lines: string[] = []; + for (const step of record.steps) { + lines.push(`── step ${String(step.step)} ──`); + if (step.retrying !== undefined) lines.push(`${MESSAGE_INDENT}↻ ${step.retrying}`); + if (step.textTail.trim().length > 0) lines.push(...step.textTail.trimEnd().split('\n')); + for (const call of step.toolCalls) { + lines.push(formatPreviewToolCall(call)); + if ( + call.result === undefined && + call.liveOutputTail !== undefined && + call.liveOutputTail.length > 0 + ) { + lines.push(`${MESSAGE_INDENT}│ ${call.liveOutputTail}`); + } + } + } + if (record.error !== undefined && record.error.length > 0) { + lines.push('Failed:', ...record.error.trimEnd().split('\n')); + } else if (record.resultSummary !== undefined && record.resultSummary.length > 0) { + lines.push('Result:', ...record.resultSummary.trimEnd().split('\n')); + } + if (lines.length === 0) { + return record.status === 'running' ? 'Waiting for activity…' : ''; + } + return lines.join('\n'); +} + +function formatPreviewToolCall(call: SubToolCallActivity): string { + const mark = call.status === 'done' ? '✓' : call.status === 'error' ? '✗' : '●'; + const verb = call.status === 'running' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(call.name, call.args); + const argStr = keyArg === null || keyArg.length === 0 ? '' : ` (${keyArg})`; + + let chip = ''; + if (call.result !== undefined) { + const callData: ToolCallBlockData = { id: call.id, name: call.name, args: call.args }; + const text = pickChip(call.name)?.(callData, call.result) ?? ''; + if (text.length > 0) chip = ` · ${text}`; + } + return `${mark} ${verb} ${call.name}${argStr}${chip}`; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 0299c6fde0b..2532f14a2d8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -80,6 +80,9 @@ export interface ModelSelectorOptions { * line; wraps instead of truncating when it exceeds the width (e.g. the * mid-conversation switch cost notice). */ readonly warning?: string; + /** Set to false to hide the Thinking footer and disable ←/→ effort + * switching — for pickers whose selection carries no thinking level. */ + readonly thinkingControl?: boolean; readonly onSelect: (selection: ModelSelection) => void; /** When provided, Alt+S invokes this instead of onSelect — used to apply the * choice to the current session only, without persisting it as the default. */ @@ -225,7 +228,10 @@ export class ModelSelectorComponent extends Container implements Focusable { } // Left/Right move the active thinking effort within the model's segments. - if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { + if ( + this.opts.thinkingControl !== false && + (matchesKey(data, Key.left) || matchesKey(data, Key.right)) + ) { const selected = this.selectedChoice(); if (selected !== undefined) { const segments = segmentsFor(selected.model); @@ -352,13 +358,13 @@ export class ModelSelectorComponent extends Container implements Focusable { lines.push(''); const selected = this.selectedChoice(); - if (selected !== undefined) { + if (selected !== undefined && this.opts.thinkingControl !== false) { const canSwitch = segmentsFor(selected.model).length > 1; const thinkingHeader = canSwitch ? ' Thinking (←→ to switch)' : ' Thinking'; lines.push(currentTheme.fg('textMuted', thinkingHeader)); lines.push(this.renderThinkingControl(selected)); + lines.push(''); } - lines.push(''); lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index 6764b88d8f3..68187349c13 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -299,6 +299,12 @@ export class QuestionDialogComponent extends Container implements Focusable { this.reviewMessage = undefined; if (this.isOtherOption(questionIdx, optionIdx)) { + if (question.multi_select && this.multiSelections[questionIdx]?.has(optionIdx)) { + this.multiSelections[questionIdx].delete(optionIdx); + this.lastAnswerMethod = method; + this.updateAnswer(questionIdx); + return; + } this.enterOtherInput(questionIdx); return; } diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index c8bd9017b5a..75c86687ffc 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -89,6 +89,8 @@ export class SessionPickerComponent extends Container implements Focusable { private visibleCount: number; private scope: 'cwd' | 'all'; private loading: boolean; + private hasMore: boolean; + private loadingMore: boolean; private list: SearchableList; focused = false; @@ -106,6 +108,14 @@ export class SessionPickerComponent extends Container implements Focusable { onCtrlD?: () => void; onToggleScope?: (selectedSessionId: string) => void; maxVisibleSessions?: number; + /** More pages exist on the backend (keyset paging). */ + hasMore?: boolean; + /** A follow-up page fetch is in flight. */ + loadingMore?: boolean; + /** Fired when the cursor reaches the end of every row fetched so far. */ + onLoadMore?: () => void; + /** Fired when a search query becomes active while pages remain unfetched. */ + onSearchDrain?: () => void; }) { super(); this.sessions = opts.sessions; @@ -117,6 +127,10 @@ export class SessionPickerComponent extends Container implements Focusable { this.onToggleScope = opts.onToggleScope; this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; this.pageSize = Math.max(1, opts.pageSize ?? 50); + this.hasMore = opts.hasMore ?? false; + this.loadingMore = opts.loadingMore ?? false; + this.onLoadMore = opts.onLoadMore; + this.onSearchDrain = opts.onSearchDrain; const initialIndex = this.resolveInitialSelectedIndex(opts.initialSelectedSessionId); this.list = new SearchableList({ items: this.sessions, @@ -133,6 +147,26 @@ export class SessionPickerComponent extends Container implements Focusable { private readonly onCtrlC?: () => void; private readonly onCtrlD?: () => void; + private readonly onLoadMore?: () => void; + private readonly onSearchDrain?: () => void; + + /** Appends a freshly fetched page, keeping the cursor and active query. */ + appendSessions(rows: SessionRow[]): void { + this.sessions = [...this.sessions, ...rows]; + this.list.setItems(this.sessions); + // Rows arriving while a query is active must become visible without + // waiting for the next keypress; only grow, never shrink the window. + this.visibleCount = Math.max( + this.visibleCount, + Math.min(this.list.view().items.length, this.pageSize), + ); + } + + /** Updates the backend-paging facts after an in-flight fetch settles. */ + setPaging(hasMore: boolean, loadingMore: boolean): void { + this.hasMore = hasMore; + this.loadingMore = loadingMore; + } private resolveInitialSelectedIndex(initialSelectedSessionId: string | undefined): number { if (initialSelectedSessionId === undefined) return 0; @@ -152,6 +186,11 @@ export class SessionPickerComponent extends Container implements Focusable { const view = this.list.view(); if (view.query !== previousQuery) { this.visibleCount = Math.min(view.items.length, this.pageSize); + // A fresh query only searches the pages fetched so far; ask the host to + // drain the rest in the background so search covers every session. + if (view.query.length > 0 && previousQuery.length === 0 && this.hasMore) { + this.onSearchDrain?.(); + } return; } @@ -159,6 +198,15 @@ export class SessionPickerComponent extends Container implements Focusable { if (view.selectedIndex >= loadedCount - 1 && loadedCount < view.items.length) { this.visibleCount = Math.min(view.items.length, this.visibleCount + this.pageSize); } + // The cursor reached the end of everything fetched: pull the next page. + if ( + this.hasMore && + !this.loadingMore && + view.items.length > 0 && + view.selectedIndex >= view.items.length - 1 + ) { + this.onLoadMore?.(); + } } handleInput(data: string): void { @@ -287,15 +335,29 @@ export class SessionPickerComponent extends Container implements Focusable { } const filteredCount = view.items.length; - if (loadedSessions.length > visibleSessions.length || view.query.length > 0) { + if ( + loadedSessions.length > visibleSessions.length || + view.query.length > 0 || + this.hasMore || + this.loadingMore + ) { lines.push(''); + const moreSuffix = this.loadingMore + ? ' · loading more…' + : this.hasMore + ? view.query.length > 0 + ? ' · searching all…' + : ' · scroll for more' + : ''; const totalSuffix = view.query.length > 0 ? `${String(loadedSessions.length)} loaded / ${String(filteredCount)} matches` - : loadedSessions.length === this.sessions.length - ? `${String(loadedSessions.length)} sessions` - : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; - const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}`; + : this.hasMore || this.loadingMore + ? `${String(loadedSessions.length)} loaded` + : loadedSessions.length === this.sessions.length + ? `${String(loadedSessions.length)} sessions` + : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; + const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}${moreSuffix}`; lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index d94de3b06dc..9726ad48324 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -41,15 +41,17 @@ export interface TabbedModelSelectorOptions { readonly selectedValue?: string; readonly currentThinkingEffort: string; /** Forwarded to each inner selector; overrides the default ' Select a model' - * title line (e.g. the secondary-model picker). */ + * title line. */ readonly title?: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; - /** Forwarded to each inner selector; when set, warning-colored lines are - * rendered directly below the key-hint line, wrapping as needed (e.g. the - * mid-conversation switch cost notice). */ + /** When set, warning-colored lines are rendered directly below the key-hint + * line, wrapping as needed (e.g. the mid-conversation switch cost notice). */ readonly warning?: string; + /** Forwarded to each inner selector; set to false to hide the Thinking + * footer and disable ←/→ effort switching. */ + readonly thinkingControl?: boolean; readonly onSelect: (selection: ModelSelection) => void; /** Forwarded to each inner selector; when set, Alt+S applies the choice to * the current session only without persisting it as the default. */ @@ -187,6 +189,7 @@ function makeSelector( searchable: true, providerSwitchHint: true, warning: opts.warning, + thinkingControl: opts.thinkingControl, onSelect: opts.onSelect, onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index c0f647f67c9..4a463671cb5 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -32,7 +32,7 @@ export interface TaskOutputViewerProps { readonly onClose: () => void; } -const STATUS_LABEL: Record = { +export const STATUS_LABEL: Record = { running: 'running', completed: 'completed', failed: 'failed', @@ -41,7 +41,7 @@ const STATUS_LABEL: Record = { lost: 'lost', }; -function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { +export function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': return 'success'; diff --git a/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts index 0ecca373255..811ad888e63 100644 --- a/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts @@ -7,6 +7,8 @@ import { type Focusable, } from '@moonshot-ai/pi-tui'; +import type { WorkspaceTrustMcpServerInfo } from '@moonshot-ai/kimi-code-sdk'; + import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; @@ -15,7 +17,7 @@ export type TrustPromptChoice = 'trust' | 'distrust'; export interface TrustPromptOptions { readonly workDir: string; /** Project-level MCP servers that trusting would enable; may be empty. */ - readonly gatedMcpServers: readonly string[]; + readonly gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[]; /** Esc resolves to 'distrust' as well. */ readonly onSelect: (choice: TrustPromptChoice) => void; } @@ -41,7 +43,7 @@ const OPTIONS: readonly TrustPromptOption[] = [ export class TrustPromptComponent implements Component, Focusable { focused = false; - private selectedIndex = 0; + private selectedIndex = 1; constructor(private readonly opts: TrustPromptOptions) {} @@ -79,12 +81,19 @@ export class TrustPromptComponent implements Component, Focusable { ]; const notice = - this.opts.gatedMcpServers.length > 0 - ? `Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They run as local processes on your machine. This folder defines: ${this.opts.gatedMcpServers.join(', ')}.` - : 'Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They run as local processes on your machine.'; + 'Project-level MCP servers are disabled until you explicitly choose Trust. Trust starts the listed project MCP targets and remembers this folder.'; for (const line of wrapTextWithAnsi(notice, Math.max(20, width - 2))) { lines.push(` ${currentTheme.fg('textMuted', line)}`); } + if (this.opts.gatedMcpServers.length > 0) { + lines.push(` ${currentTheme.fg('warning', 'Project MCP targets:')}`); + for (const server of this.opts.gatedMcpServers) { + const details = formatMcpTarget(server); + for (const line of wrapTextWithAnsi(details, Math.max(20, width - 4))) { + lines.push(` ${currentTheme.fg('warning', line)}`); + } + } + } lines.push(''); for (let i = 0; i < OPTIONS.length; i += 1) { @@ -105,3 +114,27 @@ export class TrustPromptComponent implements Component, Focusable { return lines.map((line) => truncateToWidth(line, width)); } } + +function formatMcpTarget(server: WorkspaceTrustMcpServerInfo): string { + if (server.transport === 'stdio') { + const args = server.args === undefined ? '' : ` args=${JSON.stringify(server.args)}`; + const cwd = server.cwd === undefined ? '' : ` cwd=${server.cwd}`; + return sanitizeForDisplay(`${server.name} (stdio): command=${server.command ?? ''}${args}${cwd}`); + } + return sanitizeForDisplay(`${server.name} (${server.transport}): url=${server.url ?? ''}`); +} + +/** + * Drops C0/C1 control characters (including ESC) from workspace-supplied text: + * the trust prompt renders before the workspace is trusted, so a planted + * `.mcp.json` must not inject terminal control sequences into it. + */ +function sanitizeForDisplay(value: string): string { + let result = ''; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; + result += char; + } + return result; +} diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 51958dbe966..a8b87ae0c5c 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -235,7 +235,9 @@ export class CustomEditor extends Editor { const text = this.getText(); const offset = lines.slice(0, line).reduce((sum, l) => sum + l.length + 1, 0) + start; const newText = text.slice(0, offset) + content + text.slice(offset + match[0].length); - this.setText(newText); + // Keep the paste registry intact: the text still holds other live markers + // whose entries a plain setText would drop (upstream resets the registry). + this.setText(newText, { preservePasteRegistry: true }); return true; } return false; diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index c1b39537d4c..64ed6bbf8d8 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -11,6 +11,8 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; +import { markOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; type AssistantMarkdownOptions = { @@ -61,7 +63,14 @@ export class AssistantMessageComponent implements Component { if (this.markdown === undefined || this.markdownTransient !== transient) { this.contentContainer.clear(); - this.markdown = new Markdown(displayText, 0, 0, createMarkdownTheme({ transient })); + this.markdown = new Markdown( + displayText, + 0, + 0, + createMarkdownTheme({ transient }), + undefined, + createMarkdownOptions(), + ); this.markdownTransient = transient; this.contentContainer.addChild(this.markdown); return; @@ -84,6 +93,8 @@ export class AssistantMessageComponent implements Component { 0, 0, createMarkdownTheme({ transient: this.lastTransient }), + undefined, + createMarkdownOptions(), ); this.markdownTransient = this.lastTransient; this.contentContainer.addChild(this.markdown); @@ -114,7 +125,7 @@ export class AssistantMessageComponent implements Component { i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } - const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…')); + const rendered = markOsc133Zone(lines.map((line) => truncateToWidth(line, safeWidth, '…'))); if (isRenderCacheEnabled()) { this.renderCache = { width: safeWidth, lines: rendered }; } diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index d1eeec03cba..2b46b31d366 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -10,6 +10,7 @@ import { pathToFileURL } from 'node:url'; import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; const LEFT_MARGIN = 2; // two-space indent matching other tool call children @@ -41,7 +42,7 @@ export class PlanBoxComponent implements Component { // parse + wrap output keyed on (text, width), so reusing the same // instance means repeated render() calls from the parent Container // hit the cache instead of re-parsing on every frame. - this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme); + this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme, undefined, createMarkdownOptions()); this.status = opts?.status; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 3a30649e8ba..050a9a2456f 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -412,7 +412,7 @@ function formatKeyArgument( return truncateArgValue(key, displayValue); } -function extractKeyArgument( +export function extractKeyArgument( toolName: string, args: Record, workspaceDir?: string, diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index e7241e963a3..4e61ab15c6a 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -8,6 +8,7 @@ import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; +import { markOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export class UserMessageComponent implements Component { @@ -77,15 +78,17 @@ export class UserMessageComponent implements Component { } } - const rendered = lines.map((line) => { - // Inline image sequences (Kitty / iTerm2) carry their own placement - // information and have zero visible width, but pi-tui's truncateToWidth - // treats the embedded base64 payload as visible text and would chop the - // escape sequence in half, leaving garbage like "0m...". Skip truncation - // for those lines; the image itself already respects maxWidthCells. - if (isImageLine(line)) return line; - return truncateToWidth(line, safeWidth, '…'); - }); + const rendered = markOsc133Zone( + lines.map((line) => { + // Inline image sequences (Kitty / iTerm2) carry their own placement + // information and have zero visible width, but pi-tui's truncateToWidth + // treats the embedded base64 payload as visible text and would chop the + // escape sequence in half, leaving garbage like "0m...". Skip truncation + // for those lines; the image itself already respects maxWidthCells. + if (isImageLine(line)) return line; + return truncateToWidth(line, safeWidth, '…'); + }), + ); if (isRenderCacheEnabled()) { this.renderCache = { width: safeWidth, lines: rendered }; } diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index 22e6f3bc5c8..43f9ece4126 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,6 +1,8 @@ -import { Container, Spacer } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { ACTIVITY_DETAIL_INDENT } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; @@ -8,6 +10,12 @@ export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; readonly spinner?: MoonLoader; readonly tip?: string; + /** Extra dim line rendered under the spinner (e.g. step retry error detail). */ + readonly detail?: string; +} + +export function formatActivitySpinnerTip(tip: string | undefined): string { + return tip === undefined || tip.length === 0 ? '' : ` · Tip: ${tip}`; } export class ActivityPaneComponent extends Container { @@ -22,10 +30,11 @@ export class ActivityPaneComponent extends Container { options.spinner !== undefined ) { this.addChild(new Spacer(1)); - if (options.tip) { - options.spinner.setTip(` · Tip: ${options.tip}`); - } + options.spinner.setTip(formatActivitySpinnerTip(options.tip)); this.addChild(options.spinner); + if (options.detail !== undefined && options.detail.length > 0) { + this.addChild(new Text(currentTheme.fg('textDim', options.detail), ACTIVITY_DETAIL_INDENT, 0)); + } } } diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index f32aa9321db..55ad576afd8 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -9,6 +9,7 @@ import chalk from 'chalk'; import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; import { currentTheme } from '../../theme'; +import { createMarkdownOptions } from '../../utils/markdown-options'; type BtwPanelPhase = 'running' | 'done' | 'failed'; @@ -195,7 +196,9 @@ export class BtwPanelComponent implements Component { const answer = turn.answer.trim(); const thinking = turn.thinking.trim(); if (answer.length > 0) { - lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); + lines.push( + ...new Markdown(answer, 0, 0, this.options.markdownTheme, undefined, createMarkdownOptions()).render(width), + ); } else if (thinking.length > 0) { const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( width, diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 95f40d6bbb8..5a08af8ff77 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -53,6 +53,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + render_latex: z.boolean().optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), editor: z @@ -76,6 +77,9 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + /** LaTeX math rendering in Markdown; optional only so older hand-built test + * fixtures still typecheck. */ + renderLatex: z.boolean().optional(), disablePasteBurst: z.boolean(), /** Present in every normalized config; optional only so hand-built test * fixtures from before this field existed still typecheck. */ @@ -104,6 +108,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: null, @@ -190,6 +195,7 @@ export function normalizeTuiConfig( .map((item) => item as StatusLineItem) ?? null; return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: command === undefined || command.length === 0 ? null : command, @@ -239,6 +245,7 @@ export function renderTuiConfig(config: TuiConfig): string { # Agent/runtime settings stay in ~/.kimi-code/config.toml. theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name +render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index af3637aed1f..7061d535327 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -16,6 +16,9 @@ export const EXIT_CONFIRM_WINDOW_MS = 1500; // presses far apart don't accidentally trigger undo. export const DOUBLE_ESC_WINDOW_MS = 600; +/** Session picker page size: one backend keyset page and one picker window. */ +export const SESSION_LIST_PAGE_SIZE = 50; + export function isManagedUsageProvider( providerKey: string | undefined, ): providerKey is typeof DEFAULT_OAUTH_PROVIDER_NAME { diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index baf8de08336..d3a9e45d14a 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -1,6 +1,15 @@ // Continuation indent for transcript rows that use a two-cell leading marker. export const MESSAGE_INDENT = ' '; +// OSC 133 semantic-zone markers (FinalTerm/shell-integration protocol): +// zero-width escape sequences prefixed onto the first/last rendered line of +// transcript messages. The fullscreen renderer strips them at paint and uses +// the A marker for previous/next-prompt navigation (Ctrl-Shift-Up/Down); in +// regular mode they pass through to native scrollback invisibly. +export const OSC133_ZONE_START = '\x1b]133;A\x07'; +export const OSC133_ZONE_END = '\x1b]133;B\x07'; +export const OSC133_ZONE_FINAL = '\x1b]133;C\x07'; + // Outer left/right padding applied to the transcript, panels, and the // statusline so the chrome's left edge lines up with the input box's // interior (the `>` prompt). The editor itself stays at column 0 — its @@ -12,6 +21,27 @@ export const RESULT_PREVIEW_LINES = 3; export const THINKING_PREVIEW_LINES = 2; export const COMMAND_PREVIEW_LINES = 10; +// Cap on the step-retry detail line under the waiting spinner, so huge +// provider error bodies (occasionally whole HTML error pages) can't flood +// the activity pane. +export const RETRY_DETAIL_MAX_CHARS = 160; +// Left indent (cells) for the detail line under the waiting spinner, aligning +// it with the label text: 1 (the spinner Text's own paddingX) + 2 (moon +// frame) + 1 (space between frame and label). +export const ACTIVITY_DETAIL_INDENT = 4; + +// Retention caps for the subagent activity store (background-agent detail +// view): only the most recent steps are kept, older steps are discarded +// whole, and per-step text / per-call output keep bounded tails. +export const MAX_SUBAGENT_ACTIVITY_STEPS = 20; +export const SUBAGENT_STEP_TEXT_TAIL_CHARS = 4000; +export const SUBAGENT_TOOL_OUTPUT_MAX_CHARS = 8000; +// Cap on individual string argument values kept in a record (Write/Edit +// carry whole-file contents). Only header summaries and the Edit/Write line +// chips read args, so long values are truncated; chips become approximate +// beyond the cap. +export const SUBAGENT_ARG_STRING_MAX_CHARS = 16 * 1024; + // Animation frames are shared by the login/update loaders and live thinking. export const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; export const BRAILLE_SPINNER_INTERVAL_MS = 80; diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 55df80609b9..dc86d312a5c 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -57,6 +57,7 @@ export interface EditorKeyboardHost { handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; + updateActivityPane(): void; } export class EditorKeyboardController { @@ -525,7 +526,10 @@ export class EditorKeyboardController { } this.host.setExternalEditorRunning(true); const seed = state.editor.getExpandedText?.() ?? state.editor.getText(); - state.ui.stop(); + // Fullscreen: a plain stop() would replay the whole transcript into the + // main screen on exit; the external editor only needs the alternate + // screen released, so preserve the screen instead. + state.ui.stop({ preserveScreen: state.ui.mode === 'fullscreen' ? true : undefined }); await new Promise((resolve) => { setImmediate(resolve); }); @@ -544,6 +548,11 @@ export class EditorKeyboardController { state.ui.start(); state.ui.setFocus(state.editor); state.ui.requestRender(true); + // terminal.stop() cleared the OSC 9;4 progress indicator while the + // app-side progressActive flag still reads true; resync so a turn that + // was streaming while the editor was open gets its progress back. + state.terminalState.progressActive = false; + this.host.updateActivityPane(); this.host.setExternalEditorRunning(false); } } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 55908e992ed..4448e4c3417 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -29,6 +29,7 @@ import type { TurnStartedEvent, TurnStepCompletedEvent, TurnStepInterruptedEvent, + TurnStepRetryingEvent, TurnStepStartedEvent, TokenUsage, WarningEvent, @@ -174,6 +175,7 @@ export class SessionEventHandler { private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; + private stepRetryAttemptTimer: ReturnType | undefined; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -192,6 +194,7 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); + this.clearStepRetryAttemptTimer(); this.stopAllMcpServerStatusSpinners(); } @@ -275,7 +278,7 @@ export class SessionEventHandler { case 'turn.step.started': this.handleStepBegin(event); break; case 'turn.step.interrupted': this.handleStepInterrupted(event); break; case 'turn.step.completed': this.handleStepCompleted(event); break; - case 'turn.step.retrying': break; + case 'turn.step.retrying': this.handleStepRetrying(event); break; case 'tool.progress': this.handleToolProgress(event); break; case 'shell.output': this.host.handleShellOutput(event); break; case 'shell.started': this.host.handleShellStarted(event); break; @@ -362,9 +365,14 @@ export class SessionEventHandler { private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { this.host.streamingUI.flushNow(); + this.clearStepRetry(); if (event.reason === 'cancelled') { this.markActiveAgentSwarmsCancelled(); } + // Aborted foreground subagents emit no completed/failed lifecycle event + // (v2 suppresses it for aborts), so their activity records would linger + // until the session reset — prune them when the owning turn ends. + this.subAgentEventHandler.dropForegroundOnlyActivityRecords(); if (event.reason === 'failed' && event.error?.code === 'provider.filtered') { this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error'); } @@ -418,6 +426,7 @@ export class SessionEventHandler { private handleStepCompleted(event: TurnStepCompletedEvent): void { this.host.streamingUI.flushNow(); + this.clearStepRetry(); this.host.noteStepUsage(event.usage); this.maybeShowDebugTiming(event); @@ -446,6 +455,48 @@ export class SessionEventHandler { this.host.showNotice(title, detail); } + private handleStepRetrying(event: TurnStepRetryingEvent): void { + // The failure may arrive mid-stream, after thinking/assistant deltas have + // parked the pane in `thinking`/`composing` — drive it back to waiting so + // the retry label and detail actually render during the backoff. + this.host.patchLivePane({ mode: 'waiting' }); + this.host.setAppState({ + streamingPhase: 'waiting', + stepRetry: { + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + phase: 'backoff', + }, + }); + // Both engines sleep for `delayMs` before the next attempt runs, but only + // v2 re-emits `turn.step.started` for it — flip the phase on a timer so the + // stale countdown drops on the legacy engine too. + this.clearStepRetryAttemptTimer(); + this.stepRetryAttemptTimer = setTimeout(() => { + this.stepRetryAttemptTimer = undefined; + const retry = this.host.state.appState.stepRetry; + if (retry === null) return; + this.host.setAppState({ stepRetry: { ...retry, phase: 'attempt' } }); + }, event.delayMs); + } + + private clearStepRetry(): void { + this.clearStepRetryAttemptTimer(); + if (this.host.state.appState.stepRetry === null) return; + this.host.setAppState({ stepRetry: null }); + } + + clearStepRetryAttemptTimer(): void { + if (this.stepRetryAttemptTimer !== undefined) { + clearTimeout(this.stepRetryAttemptTimer); + this.stepRetryAttemptTimer = undefined; + } + } + private maybeShowDebugTiming(event: TurnStepCompletedEvent): void { if (process.env['KIMI_CODE_DEBUG'] !== '1') return; const text = formatStepDebugTiming(event); @@ -473,6 +524,7 @@ export class SessionEventHandler { private handleStepInterrupted(event: TurnStepInterruptedEvent): void { this.host.streamingUI.flushNow(); + this.clearStepRetry(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('idle'); const reason = event.reason; @@ -625,6 +677,7 @@ export class SessionEventHandler { private handleToolResult(event: ToolResultEvent): void { const { streamingUI } = this.host; streamingUI.flushNow(); + this.clearStepRetry(); const resultData: ToolResultBlockData = { tool_call_id: event.toolCallId, output: serializeToolResultOutput(event.output), @@ -1195,6 +1248,21 @@ export class SessionEventHandler { description: info.description, status: info.status, }); + // Stopped / timed-out agents terminate without a `subagent.failed` + // event — mark the activity record here so the detail view does not + // stay "running" forever. `subagent.completed` carries the result + // summary and may land after this, so only fill still-running records. + const agentId = info.agentId; + if (agentId !== undefined) { + const record = this.subAgentEventHandler.activityStore.get(agentId); + if (record !== undefined && record.status === 'running') { + if (info.status === 'completed') { + this.subAgentEventHandler.activityStore.markCompleted(agentId); + } else { + this.subAgentEventHandler.activityStore.markFailed(agentId); + } + } + } } if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { if (info.kind === 'process' || info.kind === 'question') { diff --git a/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts b/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts new file mode 100644 index 00000000000..a612ece5b74 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts @@ -0,0 +1,347 @@ +/** + * SubagentActivityStore — per-agent activity records feeding the background + * agent detail view (AgentActivityViewer). + * + * Child-agent events arrive at `SubAgentEventHandler.routeChildAgentEvent` + * regardless of foreground/background state, but are dropped there when the + * parent tool card is gone (Ctrl+B) or never existed (run_in_background). + * This store tees those events into a bounded per-agent fold so the tasks + * browser can show what a background agent is actually doing. + * + * Retention: only the most recent `MAX_SUBAGENT_ACTIVITY_STEPS` steps are + * kept (older steps are discarded whole — a step is the core loop's natural + * "one model response + tool execution" unit, bounded by the core's own + * `turn.step.started` events). Per-step assistant text keeps a trailing + * window; per-call result output is capped. Everything lives in memory and + * is released on session switch (`clear`). + * + * Pure logic — no TUI state, no components — so it is unit-testable. + */ + +import type { Event } from '@moonshot-ai/kimi-code-sdk'; + +import { + MAX_SUBAGENT_ACTIVITY_STEPS, + SUBAGENT_ARG_STRING_MAX_CHARS, + SUBAGENT_STEP_TEXT_TAIL_CHARS, + SUBAGENT_TOOL_OUTPUT_MAX_CHARS, +} from '#/tui/constant/rendering'; +import type { ToolResultBlockData } from '../types'; +import { + argsRecord, + appendStreamingArgsPreview, + parseStreamingArgs, + serializeToolResultOutput, +} from '../utils/event-payload'; + +/** A single tool call inside a step, shaped so the viewer can feed the + * main-flow renderers (`ToolCallBlockData` / `ToolResultBlockData`). */ +export interface SubToolCallActivity { + readonly id: string; + name: string; + args: Record; + status: 'running' | 'done' | 'error'; + readonly startedAt: number; + durationMs?: number; + result?: ToolResultBlockData; + /** Last line of stdout/stderr live progress, while the call is running. */ + liveOutputTail?: string; +} + +/** One step = one core loop iteration (`turn.step.started` … next start). */ +export interface SubagentStepActivity { + readonly step: number; + /** Assistant text of this step, trailing window only. */ + textTail: string; + readonly toolCalls: SubToolCallActivity[]; + retrying?: string; +} + +export interface SubagentActivityRecord { + readonly agentId: string; + readonly agentName: string; + readonly description?: string; + readonly parentToolCallId: string; + model?: string; + effort?: string; + readonly steps: SubagentStepActivity[]; + /** Count of real `turn.step.started` events seen (monotonic). */ + totalSteps: number; + status: 'running' | 'completed' | 'failed'; + resultSummary?: string; + error?: string; + /** Bumped on every mutation; the viewer caches its render against this. */ + version: number; +} + +export interface SubagentActivitySpawn { + readonly agentId: string; + readonly agentName: string; + readonly description?: string; + readonly parentToolCallId: string; + readonly model?: string; + readonly effort?: string; +} + +const LIVE_OUTPUT_TAIL_CHARS = 200; + +function tail(text: string, maxChars: number): string { + return text.length <= maxChars ? text : text.slice(text.length - maxChars); +} + +/** Truncate long string argument values before they are retained — Write and + * Edit carry whole-file contents in args, which would otherwise dwarf every + * other retention cap. Only header summaries (`extractKeyArgument`) and the + * Edit/Write line chips read args, so truncation is display-safe; those + * chips simply become approximate beyond the cap. Shallow on purpose: the + * tools that matter have flat argument records. */ +function capArgStrings(args: Record): Record { + let capped: Record | undefined; + for (const [key, value] of Object.entries(args)) { + if (typeof value !== 'string' || value.length <= SUBAGENT_ARG_STRING_MAX_CHARS) continue; + capped ??= { ...args }; + capped[key] = `${value.slice(0, SUBAGENT_ARG_STRING_MAX_CHARS)}…`; + } + return capped ?? args; +} + +export class SubagentActivityStore { + private readonly records = new Map(); + /** Raw streaming-arguments buffer per in-flight tool call (from deltas). */ + private readonly streamingArgs = new Map(); + + ensureRecord(spawn: SubagentActivitySpawn): SubagentActivityRecord { + const existing = this.records.get(spawn.agentId); + if (existing !== undefined) { + // A resumed subagent re-spawns under the same id: keep the accumulated + // steps and flip the record back to running. + existing.status = 'running'; + existing.resultSummary = undefined; + existing.error = undefined; + return existing; + } + const record: SubagentActivityRecord = { + agentId: spawn.agentId, + agentName: spawn.agentName, + description: spawn.description, + parentToolCallId: spawn.parentToolCallId, + model: spawn.model, + effort: spawn.effort, + steps: [], + totalSteps: 0, + status: 'running', + version: 0, + }; + this.records.set(spawn.agentId, record); + return record; + } + + get(agentId: string): SubagentActivityRecord | undefined { + return this.records.get(agentId); + } + + agentIds(): readonly string[] { + return [...this.records.keys()]; + } + + applyEvent(event: Event): void { + switch (event.type) { + case 'turn.step.started': { + const record = this.recordFor(event.agentId); + record.steps.push({ step: event.step, textTail: '', toolCalls: [] }); + record.totalSteps += 1; + while (record.steps.length > MAX_SUBAGENT_ACTIVITY_STEPS) { + const evicted = record.steps.shift(); + if (evicted === undefined) break; + // A call truncated before started/result only ever produced deltas; + // its arg buffer is keyed by id, so evicting the only step that + // referenced it must drop the buffer entry too. + for (const call of evicted.toolCalls) { + this.streamingArgs.delete(this.streamKey(record.agentId, call.id)); + } + } + this.bump(record); + return; + } + case 'assistant.delta': { + const record = this.recordFor(event.agentId); + const step = this.currentStep(record); + step.textTail = tail(step.textTail + event.delta, SUBAGENT_STEP_TEXT_TAIL_CHARS); + this.bump(record); + return; + } + case 'tool.call.started': { + const record = this.recordFor(event.agentId); + const existing = this.findToolCall(record, event.toolCallId); + const args = capArgStrings(argsRecord(event.args)); + if (existing === undefined) { + this.currentStep(record).toolCalls.push({ + id: event.toolCallId, + name: event.name, + args, + status: 'running', + startedAt: Date.now(), + }); + } else { + // Authoritative full args arrive with the start; replace the + // best-effort record assembled from streaming deltas. + existing.name = event.name; + existing.args = args; + } + this.streamingArgs.delete(this.streamKey(event.agentId, event.toolCallId)); + this.bump(record); + return; + } + case 'tool.call.delta': { + const record = this.recordFor(event.agentId); + const key = this.streamKey(event.agentId, event.toolCallId); + // parseStreamingArgs only reads the preview window, so keep the raw + // buffer capped at the same size — an uncapped buffer would outgrow + // the store's retention caps on large Write/Edit argument streams. + const buffered = appendStreamingArgsPreview( + this.streamingArgs.get(key), + event.argumentsPart, + ); + this.streamingArgs.set(key, buffered); + let call = this.findToolCall(record, event.toolCallId); + if (call === undefined) { + call = { + id: event.toolCallId, + name: event.name ?? '', + args: {}, + status: 'running', + startedAt: Date.now(), + }; + this.currentStep(record).toolCalls.push(call); + } + if (call.name.length === 0 && event.name !== undefined) call.name = event.name; + call.args = capArgStrings(parseStreamingArgs(buffered)); + this.bump(record); + return; + } + case 'tool.progress': { + if (event.update.kind !== 'stdout' && event.update.kind !== 'stderr') return; + const text = event.update.text; + if (text === undefined || text.trim().length === 0) return; + const record = this.records.get(event.agentId); + const call = record === undefined ? undefined : this.findToolCall(record, event.toolCallId); + if (record === undefined || call === undefined) return; + const lines = text.trimEnd().split('\n'); + call.liveOutputTail = tail(lines.at(-1) ?? '', LIVE_OUTPUT_TAIL_CHARS); + this.bump(record); + return; + } + case 'tool.result': { + const record = this.records.get(event.agentId); + const call = record === undefined ? undefined : this.findToolCall(record, event.toolCallId); + if (record === undefined || call === undefined) return; + let output = serializeToolResultOutput(event.output); + if (output.length > SUBAGENT_TOOL_OUTPUT_MAX_CHARS) { + output = `${output.slice(0, SUBAGENT_TOOL_OUTPUT_MAX_CHARS)}\n… [output truncated to ${String(SUBAGENT_TOOL_OUTPUT_MAX_CHARS)} chars]`; + } + call.result = { + tool_call_id: call.id, + output, + is_error: event.isError, + synthetic: event.synthetic, + }; + call.status = event.isError === true ? 'error' : 'done'; + call.durationMs = Date.now() - call.startedAt; + call.liveOutputTail = undefined; + this.streamingArgs.delete(this.streamKey(event.agentId, event.toolCallId)); + this.bump(record); + return; + } + case 'turn.step.retrying': { + const record = this.recordFor(event.agentId); + const step = this.currentStep(record); + step.retrying = `retrying · attempt ${String(event.nextAttempt)}/${String(event.maxAttempts)} (${event.errorName})`; + this.bump(record); + return; + } + default: + return; + } + } + + markCompleted(agentId: string, resultSummary?: string): void { + const record = this.records.get(agentId); + if (record === undefined) return; + record.status = 'completed'; + record.resultSummary = resultSummary; + this.dropStreamingBuffers(agentId); + this.bump(record); + } + + markFailed(agentId: string, error?: string): void { + const record = this.records.get(agentId); + if (record === undefined) return; + record.status = 'failed'; + record.error = error; + this.dropStreamingBuffers(agentId); + this.bump(record); + } + + clear(): void { + this.records.clear(); + this.streamingArgs.clear(); + } + + /** Drop one agent's record and its in-flight arg buffers. Used when a + * foreground-only subagent (never backgrounded, so it can never appear in + * /tasks) reaches a terminal state — its record would otherwise stay + * resident until the session reset. */ + drop(agentId: string): void { + this.records.delete(agentId); + this.dropStreamingBuffers(agentId); + } + + /** No more deltas arrive once the record is terminal, so any buffer left + * by a call truncated before started/result can be released here. */ + private dropStreamingBuffers(agentId: string): void { + const prefix = `${agentId}:`; + for (const key of this.streamingArgs.keys()) { + if (key.startsWith(prefix)) this.streamingArgs.delete(key); + } + } + + /** Get-or-create: events can arrive for agents this process never saw a + * spawn for (e.g. switching back to a session whose background agents are + * still running) — keep their activity rather than dropping it. */ + private recordFor(agentId: string): SubagentActivityRecord { + return ( + this.records.get(agentId) ?? + this.ensureRecord({ agentId, agentName: agentId, parentToolCallId: '' }) + ); + } + + /** Latest step, creating a synthetic one when content arrives ahead of any + * `turn.step.started` (same mid-flight case as `recordFor`). */ + private currentStep(record: SubagentActivityRecord): SubagentStepActivity { + let step = record.steps.at(-1); + if (step === undefined) { + step = { step: 0, textTail: '', toolCalls: [] }; + record.steps.push(step); + } + return step; + } + + private findToolCall( + record: SubagentActivityRecord, + toolCallId: string, + ): SubToolCallActivity | undefined { + for (let i = record.steps.length - 1; i >= 0; i--) { + const call = record.steps[i]!.toolCalls.find((c) => c.id === toolCallId); + if (call !== undefined) return call; + } + return undefined; + } + + private streamKey(agentId: string, toolCallId: string): string { + return `${agentId}:${toolCallId}`; + } + + private bump(record: SubagentActivityRecord): void { + record.version += 1; + } +} diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index d8acc0cabbb..80a62510250 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -22,6 +22,7 @@ import { argsRecord, serializeToolResultOutput } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; import { nextTranscriptId } from '../utils/transcript-id'; import type { SessionEventHost } from './session-event-handler'; +import { SubagentActivityStore } from './subagent-activity-store'; export interface SubagentInfo { readonly parentToolCallId: string; @@ -56,6 +57,8 @@ export class SubAgentEventHandler { readonly subagentInfo: Map = new Map(); private readonly agentSwarmProgress: Map = new Map(); backgroundAgentMetadata: Map = new Map(); + /** Bounded per-agent activity fold feeding the background-agent detail view. */ + readonly activityStore = new SubagentActivityStore(); constructor( private readonly host: SessionEventHost, @@ -65,6 +68,7 @@ export class SubAgentEventHandler { resetRuntimeState(): void { this.subagentInfo.clear(); this.backgroundAgentMetadata.clear(); + this.activityStore.clear(); this.clearAgentSwarmProgress(); } @@ -75,6 +79,11 @@ export class SubAgentEventHandler { if (childAgentId === MAIN_AGENT_ID) return false; if (this.host.btwPanelController.routeEvent(event)) return true; + // Tee every child-agent event into the activity store before the routing + // below swallows events whose parent card is gone (Ctrl+B) or never + // existed (run_in_background) — that data is the background detail view. + this.activityStore.applyEvent(event); + const info = this.subagentInfo.get(childAgentId); if (info === undefined || info.parentToolCallId.length === 0) return true; @@ -128,8 +137,7 @@ export class SubAgentEventHandler { usage: totalUsage, // The bound model alias rides every child status update (emitted right // after spawn); surface it on the subagent card. `modelDisplayName` - // falls back to the alias itself when the entry is unknown (e.g. the - // synthesized `__secondary__` derived entry is missing). + // falls back to the alias itself when the entry is unknown. modelDisplay: event.model === undefined ? undefined @@ -283,6 +291,8 @@ export class SubAgentEventHandler { private handleSubagentCompleted( event: SubagentLifecycleEventOf<'subagent.completed'>, ): void { + this.activityStore.markCompleted(event.subagentId, event.resultSummary); + this.pruneForegroundOnlyRecord(event.subagentId); const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { const taskId = this.findAgentTaskId( @@ -312,6 +322,8 @@ export class SubAgentEventHandler { private handleSubagentFailed( event: SubagentLifecycleEventOf<'subagent.failed'>, ): void { + this.activityStore.markFailed(event.subagentId, event.error); + this.pruneForegroundOnlyRecord(event.subagentId); const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { const taskId = this.findAgentTaskId( @@ -367,6 +379,28 @@ export class SubAgentEventHandler { return match; } + /** A subagent that never became a background task (foreground-only) can + * never appear in /tasks, so its activity record is dropped at terminal + * state — otherwise records would pile up for the rest of the session. */ + private pruneForegroundOnlyRecord(subagentId: string): void { + // A spawn-time background agent keeps its record even when the + // background.task.started sync has not landed yet (short-lived agents). + if (this.backgroundAgentMetadata.has(subagentId)) return; + for (const info of this.deps.backgroundTasks.values()) { + if (info.kind === 'agent' && info.agentId === subagentId) return; + } + this.activityStore.drop(subagentId); + } + + /** Drop every foreground-only record. Called when the main turn ends: any + * foreground subagent of the turn is over at that point, and an aborted + * one emits no `subagent.completed`/`subagent.failed` to prune it. */ + dropForegroundOnlyActivityRecords(): void { + for (const agentId of this.activityStore.agentIds()) { + this.pruneForegroundOnlyRecord(agentId); + } + } + private buildBackgroundAgentMetadata( event: SubagentLifecycleEventOf<'subagent.spawned'>, ): BackgroundAgentMetadata { @@ -409,6 +443,14 @@ export class SubAgentEventHandler { runInBackground: event.runInBackground, swarmIndex: event.swarmIndex, }); + this.activityStore.ensureRecord({ + agentId: event.subagentId, + agentName: event.subagentName, + description: event.description, + parentToolCallId: event.parentToolCallId, + model: this.spawnedModelDisplay(event), + effort: this.subagentEffortDisplay(event.thinkingEffort), + }); } private handleForegroundSubagentSpawned( @@ -546,8 +588,7 @@ export class SubAgentEventHandler { // The bound model alias rides every child status update (emitted right // after spawn). Swarm members share one binding, so the panel shows it // once in the header instead of per cell. `modelDisplayName` falls back - // to the alias itself when the entry is unknown (e.g. the synthesized - // `__secondary__` derived entry is missing). + // to the alias itself when the entry is unknown. progress.setModelDisplay( modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), ); @@ -619,8 +660,11 @@ export class SubAgentEventHandler { } const width = Math.floor(terminalColumns); + const dock = state.dockContainer; + // Fullscreen: the root children are empty (layout root holds a ScrollView + + // dock); the chrome below the transcript is the dock's children instead. const rowsAfterSwarm = renderedRowsAfterChild( - state.ui.children, + dock !== undefined ? [state.transcriptContainer, ...dock.children] : state.ui.children, state.transcriptContainer, width, ); diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 6994b13b869..7db2f0a82fc 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,10 +1,18 @@ import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Component, ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; +import type { ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; +import { AgentActivityViewer, formatSubagentActivityPreview } from '../components/dialogs/agent-activity-viewer'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; import type { Theme } from '#/tui/theme'; import type { CustomEditor } from '../components/editor/custom-editor'; +import { + beginScreenTakeover, + endScreenTakeover, + type ScreenTakeover, +} from '../utils/screen-takeover'; +import type { SessionEventHandler } from './session-event-handler'; +import type { SubagentActivityRecord } from './subagent-activity-store'; export interface TasksBrowserHost { readonly state: { @@ -15,6 +23,7 @@ export interface TasksBrowserHost { readonly editor: CustomEditor; }; readonly backgroundTasks: ReadonlyMap; + readonly sessionEventHandler: SessionEventHandler; readonly session: Session | undefined; showError(msg: string): void; setTasksBrowser(value: TasksBrowserState | undefined): void; @@ -22,7 +31,7 @@ export interface TasksBrowserHost { export type TasksBrowserState = { component: TasksBrowserApp; - savedChildren: readonly Component[]; + takeover: ScreenTakeover; filter: TasksFilter; selectedTaskId: string | undefined; tailOutput: string | undefined; @@ -33,8 +42,8 @@ export type TasksBrowserState = { pollTimer: NodeJS.Timeout | undefined; viewer: | { - component: TaskOutputViewer; - savedChildren: readonly Component[]; + component: TaskOutputViewer | AgentActivityViewer; + takeover: ScreenTakeover; taskId: string; output: string; refreshId: number; @@ -82,9 +91,7 @@ export class TasksBrowserController { state.terminal, ); - const savedChildren = [...state.ui.children]; - state.ui.clear(); - state.ui.addChild(component); + const takeover = beginScreenTakeover(state.ui, component); state.ui.setFocus(component); state.ui.requestRender(true); @@ -94,7 +101,7 @@ export class TasksBrowserController { this.host.setTasksBrowser({ component, - savedChildren, + takeover, filter, selectedTaskId, tailOutput: undefined, @@ -119,10 +126,7 @@ export class TasksBrowserController { if (browser.pollTimer !== undefined) clearInterval(browser.pollTimer); if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); - state.ui.clear(); - for (const child of browser.savedChildren) { - state.ui.addChild(child); - } + endScreenTakeover(state.ui, browser.takeover); this.host.setTasksBrowser(undefined); state.ui.setFocus(state.editor); state.ui.requestRender(true); @@ -140,6 +144,8 @@ export class TasksBrowserController { const browser = state.tasksBrowser; const viewer = browser?.viewer; if (browser === undefined || viewer === undefined) return; + // The agent activity viewer refreshes from the local store, not the RPC. + if (viewer.component instanceof AgentActivityViewer) return; const session = this.host.session; if (session === undefined) return; @@ -214,9 +220,26 @@ export class TasksBrowserController { return; } if (state.tasksBrowser !== browser) return; + this.syncAgentPreview(); this.pushProps(tasks); } + /** Agent tasks capture output only on completion, so while one is selected + * the Preview frame is fed from the in-memory activity store instead. */ + private syncAgentPreview(): void { + const browser = this.host.state.tasksBrowser; + const selectedTaskId = browser?.selectedTaskId; + if (browser === undefined || selectedTaskId === undefined) return; + const info = this.host.backgroundTasks.get(selectedTaskId); + if (info?.kind !== 'agent' || info.agentId === undefined) return; + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record === undefined) return; + browser.tailOutput = formatSubagentActivityPreview(record); + browser.tailLoading = false; + } + private pushProps(tasks: readonly BackgroundTaskInfo[]): void { const browser = this.host.state.tasksBrowser; if (browser === undefined) return; @@ -317,6 +340,20 @@ export class TasksBrowserController { if (browser === undefined) return; if (browser.viewer !== undefined) return; + // Agent tasks get the activity detail view when this process holds a + // record for the agent; otherwise (e.g. a `lost` task after resume) fall + // through to the captured-output viewer. + const info = this.host.backgroundTasks.get(taskId); + if (info !== undefined && info.kind === 'agent' && info.agentId !== undefined) { + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record !== undefined) { + this.openAgentActivityViewer(taskId, info, record); + return; + } + } + const session = this.host.session; if (session === undefined) { this.flash('No active session.'); @@ -334,7 +371,6 @@ export class TasksBrowserController { const current = state.tasksBrowser; if (current === undefined || current !== browser) return; - const info = this.host.backgroundTasks.get(taskId); const viewer = new TaskOutputViewer( { taskId, @@ -347,9 +383,7 @@ export class TasksBrowserController { state.terminal, ); - const savedBrowserChildren = [...state.ui.children]; - state.ui.clear(); - state.ui.addChild(viewer); + const takeover = beginScreenTakeover(state.ui, viewer); state.ui.setFocus(viewer); state.ui.requestRender(true); @@ -359,7 +393,7 @@ export class TasksBrowserController { browser.viewer = { component: viewer, - savedChildren: savedBrowserChildren, + takeover, taskId, output, refreshId: 0, @@ -367,11 +401,88 @@ export class TasksBrowserController { }; } + private openAgentActivityViewer( + taskId: string, + info: BackgroundTaskInfo, + record: SubagentActivityRecord, + ): void { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined || browser.viewer !== undefined) return; + + const viewer = new AgentActivityViewer( + { + taskId, + info, + record, + onClose: () => { + this.closeOutputViewer(); + }, + }, + state.terminal, + ); + + const takeover = beginScreenTakeover(state.ui, viewer); + state.ui.setFocus(viewer); + state.ui.requestRender(true); + + // The activity store is in-memory — refreshing is a local read, no RPC. + const pollTimer = setInterval(() => { + this.refreshAgentActivityViewer(); + }, 1000); + + browser.viewer = { + component: viewer, + takeover, + taskId, + output: '', + refreshId: 0, + pollTimer, + }; + } + + private refreshAgentActivityViewer(): void { + const { state } = this.host; + const viewer = state.tasksBrowser?.viewer; + if (viewer === undefined || !(viewer.component instanceof AgentActivityViewer)) return; + + const info = this.host.backgroundTasks.get(viewer.taskId); + const agentId = info?.kind === 'agent' ? info.agentId : undefined; + const record = + agentId === undefined + ? undefined + : this.host.sessionEventHandler.subAgentEventHandler.activityStore.get(agentId); + viewer.component.setProps({ + taskId: viewer.taskId, + info, + record, + onClose: () => { + this.closeOutputViewer(); + }, + }); + state.ui.requestRender(); + } + private loadTail(taskId: string): void { const { state } = this.host; const browser = state.tasksBrowser; if (browser === undefined) return; + // Agent tasks capture output only on completion — serve the preview from + // the in-memory activity store instead of the RPC when a record exists. + const info = this.host.backgroundTasks.get(taskId); + if (info !== undefined && info.kind === 'agent' && info.agentId !== undefined) { + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record !== undefined) { + browser.tailOutput = formatSubagentActivityPreview(record); + browser.tailLoading = false; + this.repaint(); + return; + } + } + const session = this.host.session; if (session === undefined) { browser.tailLoading = false; @@ -423,10 +534,7 @@ export class TasksBrowserController { const viewer = browser.viewer; clearInterval(viewer.pollTimer); browser.viewer = undefined; - this.host.state.ui.clear(); - for (const child of viewer.savedChildren) { - this.host.state.ui.addChild(child); - } + endScreenTakeover(this.host.state.ui, viewer.takeover); this.host.state.ui.setFocus(browser.component); this.host.state.ui.requestRender(true); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 085a922d7ae..5bb4ee6b600 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -24,6 +24,8 @@ import { type Focusable, getCapabilities, Spacer, + TuiAltScreen, + TuiMainScreen, } from '@moonshot-ai/pi-tui'; import { resolve } from 'pathe'; @@ -105,6 +107,7 @@ import { MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, PRODUCT_NAME, + SESSION_LIST_PAGE_SIZE, SESSIONLESS_STARTUP_NOTICE, } from './constant/kimi-tui'; import { CHROME_GUTTER } from './constant/rendering'; @@ -136,6 +139,7 @@ import { type LoginProgressSpinnerHandle, type QueuedMessage, type SteerInputItem, + type StepRetryState, type TranscriptEntry, type TUIStartupOptions, type TUIStartupState, @@ -151,7 +155,9 @@ import { installInputLatencyProbe } from './utils/input-latency'; import { startupTrace } from '#/utils/startup-trace'; import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; +import { beginScreenTakeover, endScreenTakeover, type ScreenTakeover } from './utils/screen-takeover'; import { sessionRowsForPicker } from './utils/session-picker-rows'; +import { formatStepRetryDetail, formatStepRetryLabel } from './utils/step-retry'; import { formatBashOutputForDisplay } from './utils/shell-output'; import { thinkingEffortFromConfig } from './utils/thinking-config'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; @@ -210,6 +216,10 @@ function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undef return undefined; } +function waitingSpinnerLabel(retry: StepRetryState | null): string { + return retry === null ? '' : formatStepRetryLabel(retry); +} + function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); } @@ -242,10 +252,12 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, theme: input.tuiConfig.theme, version: input.version, editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, + renderLatex: input.tuiConfig.renderLatex, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, @@ -328,7 +340,10 @@ export class KimiTUI { private pluginCommands: readonly KimiSlashCommand[] = []; readonly pluginCommandMap = new Map(); private readonly imageStore = new ImageAttachmentStore(); - private fdPath: string | null = detectFdPath(); + // Detected lazily in startBackgroundFdAutocomplete() — detection spawns + // `fd --version`, which must not happen before the workspace trust gate: + // on Windows a bare command name resolves into the (untrusted) cwd first. + private fdPath: string | null = null; private fdDownloadStarted = false; sessionEventUnsubscribe: (() => void) | undefined; cancelInFlight: (() => void) | undefined; @@ -373,12 +388,13 @@ export class KimiTUI { // preview viewer can restore focus to the exact same instance (and its // selection / feedback state) when it closes. private activeApprovalPanel: ApprovalPanelComponent | undefined; - // Active full-screen approval preview. While set, the root UI's normal - // children are stashed in `savedChildren`; closing restores them. + // Active full-screen approval preview. While set, the previous screen is + // stashed in `takeover` (root children in regular mode, the layout root in + // fullscreen); closing restores it. private approvalPreview: | { component: ApprovalPreviewViewer; - savedChildren: readonly Component[]; + takeover: ScreenTakeover; panel: ApprovalPanelComponent; } | undefined; @@ -587,9 +603,19 @@ export class KimiTUI { this.registerSignalHandlers(); // Outer try rolls back signal listeners on startup failure. try { + // The workspace trust gate must run before anything else in startup — + // including the migration branch: a workspace that needs migration is + // not implicitly trusted, and later startup steps spawn child processes. + startupTrace('trustPrompt:begin'); + const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); + startupTrace('trustPrompt:end'); + if (this.migrationPlan !== null) { // Migration needs the event loop running first (pi-tui component). - this.startEventLoop(); + // When the trust prompt already started it, starting it again would + // re-run pi-tui's terminal.start() — stacking a second Kitty + // keyboard-protocol push and duplicate stdin listeners. + if (!trustPromptStartedLoop) this.startEventLoop(); try { const migrationResult = await this.runMigrationScreen(this.migrationPlan); if (this.migrateOnly) { @@ -610,9 +636,6 @@ export class KimiTUI { return; } - startupTrace('trustPrompt:begin'); - const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); - startupTrace('trustPrompt:end'); startupTrace('initMainTui:begin'); const shouldReplayHistory = await this.initMainTui(); startupTrace('initMainTui:end'); @@ -725,9 +748,15 @@ export class KimiTUI { } private startBackgroundFdAutocomplete(): void { - if (this.fdPath !== null || this.fdDownloadStarted) return; + if (this.fdDownloadStarted) return; this.fdDownloadStarted = true; + this.fdPath = detectFdPath(); + if (this.fdPath !== null) { + this.setupAutocomplete(); + return; + } + void ensureFdPath() .then((fdPath) => { if (fdPath === null) return; @@ -870,8 +899,10 @@ export class KimiTUI { }); shouldReplayHistory = true; } else { - const sessions = await this.harness.listSessions({ workDir }); - const target = sessions[0]; + // Only the most recent session matters here — fetch a one-item page + // instead of materializing the whole listing. + const page = await this.harness.listSessionsPage({ workDir, limit: 1 }); + const target = page.items[0]; if (target !== undefined) { session = await this.harness.resumeSession({ id: target.id, @@ -962,6 +993,7 @@ export class KimiTUI { await this.harness.close(); } finally { this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.sessionEventHandler.clearStepRetryAttemptTimer(); this.uninstallRainbowDance(); try { await this.state.terminal.drainInput(); @@ -969,7 +1001,7 @@ export class KimiTUI { // best effort — the terminal may already be dead (SIGHUP / EIO). } try { - this.state.ui.stop(); + this.stopUiForExit(); } catch { // best effort terminal restore. } @@ -1054,6 +1086,9 @@ export class KimiTUI { private buildLayout(): void { const { ui } = this.state; + // Fullscreen mounts its layout root (transcript ScrollView + bottom dock) + // in createTUIState; the root children list stays empty there. + if (ui instanceof TuiAltScreen) return; ui.clear(); ui.addChild(this.state.transcriptContainer); ui.addChild(this.state.activityContainer); @@ -1072,9 +1107,43 @@ export class KimiTUI { private mountFooter(): void { const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); footerWrap.addChild(this.state.footer); + const dock = this.state.dockContainer; + if (dock !== undefined) { + // Dock sizing contract: the footer may shrink to 1 row under extreme + // height pressure, but never disappears (see createTUIState). + dock.addChild(footerWrap, { shrink: 1, minSize: 1 }); + return; + } this.state.ui.addChild(footerWrap); } + // Fullscreen exit: leave the alternate screen with the frame preserved, + // then replay the transcript through a main-screen renderer so native + // scrollback ends up with the same inline layout a regular session would + // have produced (pi's "transcript" exit form). + private stopUiForExit(): void { + const ui = this.state.ui; + if (!(ui instanceof TuiAltScreen)) { + ui.stop(); + return; + } + ui.stop({ preserveScreen: true }); + const main = new TuiMainScreen(ui.terminal); + main.addChild(this.state.transcriptContainer); + main.addChild(this.state.activityContainer); + main.addChild(this.state.todoPanelContainer); + main.addChild(this.state.queueContainer); + main.addChild(this.state.btwPanelContainer); + main.addChild(this.state.editorContainer); + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(this.state.footer); + main.addChild(footerWrap); + // First paint of a main-screen renderer writes every line sequentially, + // landing the whole transcript in native scrollback. + main.renderNow(); + main.stop(); + } + // ========================================================================= // Input Dispatch // ========================================================================= @@ -1962,13 +2031,16 @@ export class KimiTUI { async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise { this.state.loadingSessions = true; this.state.sessionsScope = scope; + this.state.sessionsNextCursor = undefined; + this.state.sessionsLoadingMore = false; try { - const sessions = - scope === 'all' - ? await this.harness.listSessions({}) - : await this.harness.listSessions({ workDir: this.state.appState.workDir }); + const page = await this.harness.listSessionsPage({ + workDir: scope === 'all' ? undefined : this.state.appState.workDir, + limit: SESSION_LIST_PAGE_SIZE, + }); + this.state.sessionsNextCursor = page.nextCursor; this.state.sessions = sessionRowsForPicker( - sessions, + page.items, this.state.appState.sessionId, this.hasSessionContent(), ); @@ -1982,6 +2054,81 @@ export class KimiTUI { } } + /** + * Pulls the next keyset page into the session picker (scroll-bottom paging). + * A scope switch or picker close bumps `sessionPickerScopeRequestToken`, + * which makes an in-flight append discard its result. Returns whether a page + * was appended — callers draining pages stop on the first `false`. + * Scroll triggers pass no argument and are dropped while a fetch is running; + * the search drain passes `waitForInFlight` to join the running fetch and + * continue with the next page, so a query typed mid-fetch still ends up + * covering every session. + */ + private async fetchMoreSessions(waitForInFlight = false): Promise { + while (this.sessionsPageFetchInFlight !== undefined) { + if (!waitForInFlight) return false; + await this.sessionsPageFetchInFlight; + } + const cursor = this.state.sessionsNextCursor; + if (cursor === undefined) return false; + const requestToken = this.sessionPickerScopeRequestToken; + this.state.sessionsLoadingMore = true; + this.sessionPickerComponent?.setPaging(true, true); + this.state.ui.requestRender(); + const run = this.appendNextSessionPage(cursor, requestToken); + this.sessionsPageFetchInFlight = run; + try { + return await run; + } finally { + if (this.sessionsPageFetchInFlight === run) this.sessionsPageFetchInFlight = undefined; + } + } + + private async appendNextSessionPage(cursor: string, requestToken: number): Promise { + try { + const page = await this.harness.listSessionsPage({ + workDir: this.state.sessionsScope === 'all' ? undefined : this.state.appState.workDir, + limit: SESSION_LIST_PAGE_SIZE, + before: cursor, + }); + if (requestToken !== this.sessionPickerScopeRequestToken) return false; + this.state.sessionsNextCursor = page.nextCursor; + const rows = sessionRowsForPicker( + page.items, + this.state.appState.sessionId, + this.hasSessionContent(), + ); + this.state.sessions = [...this.state.sessions, ...rows]; + this.sessionPickerComponent?.appendSessions(rows); + this.sessionPickerComponent?.setPaging(page.nextCursor !== undefined, false); + return true; + } catch (error) { + log.warn('failed to fetch more sessions for picker', { error: String(error) }); + return false; + } finally { + if (requestToken === this.sessionPickerScopeRequestToken) { + this.state.sessionsLoadingMore = false; + this.sessionPickerComponent?.setPaging(this.state.sessionsNextCursor !== undefined, false); + this.state.ui.requestRender(); + } + } + } + + /** + * Search covers every session: while a query is active the picker asks for + * all remaining pages, drained one at a time in the background. A failed or + * superseded fetch stops the drain (the next fresh query re-triggers it). + */ + private async drainSessionsForSearch(): Promise { + const requestToken = this.sessionPickerScopeRequestToken; + while ( + this.state.sessionsNextCursor !== undefined && + requestToken === this.sessionPickerScopeRequestToken + ) { + if (!(await this.fetchMoreSessions(true))) return; + } + } + updateTerminalTitle(): void { const trimmed = this.state.appState.sessionTitle?.trim() ?? ''; const label = trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; @@ -2704,7 +2851,13 @@ export class KimiTUI { } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); - const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`; + // Carry the retry state in the mode key so an incoming/cleared + // `turn.step.retrying` rebuilds the waiting pane with fresh label and + // detail instead of hitting the cached-pane early return below. + const retry = effectiveMode === 'waiting' ? this.state.appState.stepRetry : null; + const retryKey = + retry === null ? '' : `${formatStepRetryLabel(retry)}|${formatStepRetryDetail(retry)}`; + const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}:${retryKey}`; if ( activityModeKey === this.lastActivityMode && @@ -2726,14 +2879,16 @@ export class KimiTUI { this.state.ui.requestRender(); return; case 'waiting': { - const spinner = this.ensureActivitySpinner('moon'); + const stepRetry = this.state.appState.stepRetry; + const spinner = this.ensureActivitySpinner('moon', waitingSpinnerLabel(stepRetry)); this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'waiting', spinner, - tip: this.currentLoadingTip?.tip, + tip: stepRetry === null ? this.currentLoadingTip?.tip : undefined, + detail: stepRetry === null ? undefined : formatStepRetryDetail(stepRetry), }), ); break; @@ -3225,6 +3380,8 @@ export class KimiTUI { forwardEditorExit: false, }; private sessionPickerScopeRequestToken = 0; + private sessionPickerComponent: SessionPickerComponent | undefined; + private sessionsPageFetchInFlight: Promise | undefined; async showSessionPicker(): Promise { await this.openSessionPicker({ @@ -3296,6 +3453,7 @@ export class KimiTUI { hideSessionPicker(): void { this.sessionPickerScopeRequestToken += 1; + this.sessionPickerComponent = undefined; this.editorKeyboard.clearPendingExit(); this.state.activeDialog = null; this.restoreEditor(); @@ -3316,29 +3474,37 @@ export class KimiTUI { readonly applyStartupModes?: boolean; }): void { this.state.activeDialog = 'session-picker'; - this.mountEditorReplacement( - new SessionPickerComponent({ - sessions: this.state.sessions, - loading: this.state.loadingSessions, - currentSessionId: this.state.appState.sessionId, - scope: this.state.sessionsScope, - initialSelectedSessionId: options.initialSelectedSessionId, - pageSize: 50, - onSelect: (session: SessionRow) => { - void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( - (error) => { - this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); - }, - ); - }, - onCancel: options.onCancel, - onCtrlC: options.onCtrlC, - onCtrlD: options.onCtrlD, - onToggleScope: (selectedSessionId: string) => { - void this.toggleSessionPickerScope(selectedSessionId); - }, - }), - ); + const picker = new SessionPickerComponent({ + sessions: this.state.sessions, + loading: this.state.loadingSessions, + currentSessionId: this.state.appState.sessionId, + scope: this.state.sessionsScope, + initialSelectedSessionId: options.initialSelectedSessionId, + pageSize: SESSION_LIST_PAGE_SIZE, + hasMore: this.state.sessionsNextCursor !== undefined, + loadingMore: this.state.sessionsLoadingMore, + onLoadMore: () => { + void this.fetchMoreSessions(); + }, + onSearchDrain: () => { + void this.drainSessionsForSearch(); + }, + onSelect: (session: SessionRow) => { + void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( + (error) => { + this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); + }, + ); + }, + onCancel: options.onCancel, + onCtrlC: options.onCtrlC, + onCtrlD: options.onCtrlD, + onToggleScope: (selectedSessionId: string) => { + void this.toggleSessionPickerScope(selectedSessionId); + }, + }); + this.sessionPickerComponent = picker; + this.mountEditorReplacement(picker); } private async handleSessionPickerSelect( @@ -3393,12 +3559,12 @@ export class KimiTUI { // Mounts the full-screen approval preview viewer on top of the current // approval panel. Uses the same nested-takeover pattern as - // openTaskOutputViewer: we snapshot the root container's children, swap - // in the viewer, and restore on close. The approval panel instance is + // openTaskOutputViewer: beginScreenTakeover swaps the viewer in (root + // children in regular mode, layout root in fullscreen) and closing restores + // it. The approval panel instance is // kept around in `activeApprovalPanel` so its selection state survives. private openApprovalPreview(panel: ApprovalPanelComponent, block: ApprovalPreviewBlock): void { if (this.approvalPreview !== undefined) return; - const savedChildren = [...this.state.ui.children]; const viewer = new ApprovalPreviewViewer( { block, @@ -3408,21 +3574,17 @@ export class KimiTUI { }, this.state.terminal, ); - this.state.ui.clear(); - this.state.ui.addChild(viewer); + const takeover = beginScreenTakeover(this.state.ui, viewer); this.state.ui.setFocus(viewer); this.state.ui.requestRender(true); - this.approvalPreview = { component: viewer, savedChildren, panel }; + this.approvalPreview = { component: viewer, takeover, panel }; } private closeApprovalPreview(): void { const preview = this.approvalPreview; if (preview === undefined) return; this.approvalPreview = undefined; - this.state.ui.clear(); - for (const child of preview.savedChildren) { - this.state.ui.addChild(child); - } + endScreenTakeover(this.state.ui, preview.takeover); this.state.ui.setFocus(preview.panel); this.state.ui.requestRender(true); } diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index d9207f9f655..c9a151ab500 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -1,12 +1,18 @@ import { Container, ProcessTerminal, - TUI, + ScrollView, + TuiAltScreen, + TuiMainScreen, + VStack, + type TUI, } from '@moonshot-ai/pi-tui'; import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; -import { FooterComponent } from './components/chrome/footer'; -import { GutterContainer } from './components/chrome/gutter-container'; +import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { openUrl } from '#/utils/open-url'; + +import { FooterComponent } from './components/chrome/footer';import { GutterContainer } from './components/chrome/gutter-container'; import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import type { SessionRow } from './components/dialogs/session-picker'; @@ -15,6 +21,7 @@ import { DEFAULT_TUI_CONFIG } from './config'; import { CHROME_GUTTER } from './constant/rendering'; import type { TasksBrowserState } from './controllers/tasks-browser'; import { currentTheme, type Theme } from './theme'; +import { setMarkdownRenderLatex } from './utils/markdown-options'; import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { INITIAL_LIVE_PANE, @@ -36,6 +43,12 @@ export interface TUIState { queueContainer: Container; btwPanelContainer: Container; editorContainer: Container; + /** + * Fullscreen mode only: the bottom dock (activity/todo/queue/btw/editor + + * footer) stacked under the transcript ScrollView. Undefined in regular + * mode, where all chrome is a direct child of the root container. + */ + dockContainer: VStack | undefined; footer: FooterComponent; editor: CustomEditor; theme: Theme; @@ -48,6 +61,10 @@ export interface TUIState { toolOutputExpanded: boolean; sessions: SessionRow[]; loadingSessions: boolean; + /** Keyset cursor for the next older page; `undefined` when the listing is exhausted. */ + sessionsNextCursor: string | undefined; + /** A follow-up session page fetch is in flight. */ + sessionsLoadingMore: boolean; sessionsScope: 'cwd' | 'all'; activeDialog: 'session-picker' | 'help' | 'trust-prompt' | 'cache-hint' | null; tasksBrowser: TasksBrowserState | undefined; @@ -76,7 +93,32 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const theme = currentTheme; const terminal = new ProcessTerminal(); - const ui = new TUI(terminal); + setMarkdownRenderLatex(initialAppState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true); + // Fullscreen is experimental and env-gated for now: KIMI_CODE_TUI_FULL_SCREEN=1. + const fullscreen = process.env['KIMI_CODE_TUI_FULL_SCREEN'] === '1'; + const ui = + fullscreen + ? new TuiAltScreen(terminal, undefined, undefined, { + // Mouse capture takes over the terminal's native link activation, so + // route OSC 8 clicks through our own opener. + openUrl, + // Likewise, on Windows the terminal's native right-click paste is + // intercepted; feed the clipboard to the focused component as a + // bracketed paste instead (renderer only calls this on win32). + onRightClickPaste: () => { + const target = ui.getFocusedComponent(); + if (!target?.handleInput || clipboard?.getText === undefined) return; + void clipboard + .getText() + .then((text) => { + if (!text || ui.getFocusedComponent() !== target) return; + target.handleInput?.(`\x1b[200~${text}\x1b[201~`); + ui.requestRender(); + }) + .catch(() => {}); + }, + }) + : new TuiMainScreen(terminal); const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); @@ -92,6 +134,33 @@ export function createTUIState(options: KimiTUIOptions): TUIState { ui.requestRender(); }); + let dockContainer: VStack | undefined; + if (ui instanceof TuiAltScreen) { + // Fullscreen (alternate screen): the transcript scrolls inside the primary + // ScrollView while the rest of the chrome stays docked at the bottom. The + // footer joins the dock later via mountFooter(). + // Sizing contract (mirrors pi's interactive layout): the transcript starts + // from basis 0 and grows; the dock keeps its intrinsic height, with the + // editor never squeezed below its 3 rows (top border / input / bottom + // border) and the footer below 1 — otherwise the box outline gets clipped. + const scrollView = new ScrollView(transcriptContainer, { + follow: 'end', + primary: true, + overscroll: 'chain', + scrollbar: 'auto', + }); + dockContainer = new VStack(); + dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(queueContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(btwPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 }); + const root = new VStack(); + root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 }); + root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 }); + ui.setLayoutRoot(root); + } + return { ui, terminal, @@ -102,6 +171,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { queueContainer, btwPanelContainer, editorContainer, + dockContainer, editor, footer, theme, @@ -114,6 +184,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { toolOutputExpanded: false, sessions: [], loadingSessions: false, + sessionsNextCursor: undefined, + sessionsLoadingMore: false, sessionsScope: 'cwd', activeDialog: null, tasksBrowser: undefined, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 55a16da45c5..6197112e69d 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -65,11 +65,15 @@ export interface AppState { isReplaying: boolean; streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; + /** Pending step retry backoff (fed by `turn.step.retrying`); null when no retry is in flight. */ + stepRetry: StepRetryState | null; theme: ThemeName; version: string; editorCommand: string | null; /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ disablePasteBurst?: boolean; + /** LaTeX math rendering in Markdown; defaults to true when absent from older fixtures. */ + renderLatex?: boolean; /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ cacheExpiryHint?: boolean; notifications: NotificationsConfig; @@ -86,6 +90,24 @@ export interface AppState { banner?: BannerState | null; } +export interface StepRetryState { + /** Upcoming attempt number (1-based). */ + nextAttempt: number; + maxAttempts: number; + /** Backoff wait before the next attempt, in milliseconds. */ + delayMs: number; + errorName: string; + errorMessage: string; + /** HTTP status code for `APIStatusError`; undefined for network/timeout failures. */ + statusCode?: number; + /** + * `backoff` while sleeping before the next attempt (label shows the + * countdown); `attempt` once the `delayMs` backoff has elapsed and the next + * attempt is running — the countdown has expired by then and is dropped. + */ + phase: 'backoff' | 'attempt'; +} + export interface ToolCallBlockData { id: string; name: string; diff --git a/apps/kimi-code/src/tui/utils/markdown-options.ts b/apps/kimi-code/src/tui/utils/markdown-options.ts new file mode 100644 index 00000000000..d765e599b17 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/markdown-options.ts @@ -0,0 +1,21 @@ +/** + * Shared Markdown behavior options (distinct from the visual theme). + * + * Holds the process-wide LaTeX toggle from tui.toml so transcript components + * don't each need the config threaded through construction. Mirrors the + * render-cache toggle pattern (see utils/render-cache.ts). + */ + +import type { MarkdownOptions } from '@moonshot-ai/pi-tui'; + +// Default on, matching upstream pi-tui; overridden from tui.toml at startup +// and on /reload. +let renderLatex = true; + +export function setMarkdownRenderLatex(value: boolean): void { + renderLatex = value; +} + +export function createMarkdownOptions(): MarkdownOptions { + return { renderLatex }; +} diff --git a/apps/kimi-code/src/tui/utils/osc133.ts b/apps/kimi-code/src/tui/utils/osc133.ts new file mode 100644 index 00000000000..3273fe15aa2 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/osc133.ts @@ -0,0 +1,34 @@ +/** + * OSC 133 zone marking for transcript messages. The fullscreen renderer + * anchors previous/next-prompt navigation on lines whose first bytes are an + * OSC 133;A zone marker (and strips the markers at paint), so the marks must + * survive every container between the message component and the ScrollView. + */ + +import { + OSC133_ZONE_END, + OSC133_ZONE_FINAL, + OSC133_ZONE_START, +} from '#/tui/constant/rendering'; + +// One or more consecutive A/B/C zone markers anchored at the line start. +const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; + +/** + * Mark a message's rendered lines as a semantic zone: A on the first line, + * B+C on the last. Mutates and returns the given array — call it on freshly + * built lines before handing them to a render cache (cached lines then + * already carry the marks, so they are never marked twice). + */ +export function markOsc133Zone(lines: string[]): string[] { + if (lines.length === 0) return lines; + lines[0] = OSC133_ZONE_START + lines[0]!; + lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1]!; + return lines; +} + +/** Prefix a rendered line while keeping any leading OSC 133 zone at byte 0. */ +export function prefixPreservingOsc133Zone(line: string, prefix: string): string { + const zone = OSC133_ZONE_PREFIX.exec(line)?.[0]; + return zone === undefined ? prefix + line : zone + prefix + line.slice(zone.length); +} diff --git a/apps/kimi-code/src/tui/utils/screen-takeover.ts b/apps/kimi-code/src/tui/utils/screen-takeover.ts new file mode 100644 index 00000000000..05107a84e73 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/screen-takeover.ts @@ -0,0 +1,38 @@ +/** + * Mode-aware full-screen viewer takeover. + * + * In regular mode a viewer is mounted by snapshotting the root container's + * children and swapping the viewer in. In fullscreen (alternate screen) the + * root children are not painted at all — the layout root is — so the viewer + * must become the layout root instead. Both shapes restore cleanly and nest + * (a viewer opened from another viewer). + */ + +import type { Component, TUI } from '@moonshot-ai/pi-tui'; +import { TuiAltScreen } from '@moonshot-ai/pi-tui'; + +/** Restore data for a screen takeover; opaque to callers. */ +export type ScreenTakeover = + | { readonly kind: 'children'; readonly children: readonly Component[] } + | { readonly kind: 'root'; readonly root: Component | undefined }; + +export function beginScreenTakeover(ui: TUI, viewer: Component): ScreenTakeover { + if (ui instanceof TuiAltScreen) { + const root = ui.getLayoutRoot(); + ui.setLayoutRoot(viewer); + return { kind: 'root', root }; + } + const children = [...ui.children]; + ui.clear(); + ui.addChild(viewer); + return { kind: 'children', children }; +} + +export function endScreenTakeover(ui: TUI, takeover: ScreenTakeover): void { + if (takeover.kind === 'root') { + if (ui instanceof TuiAltScreen) ui.setLayoutRoot(takeover.root); + return; + } + ui.clear(); + for (const child of takeover.children) ui.addChild(child); +} diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts index 00a920e1ffd..20770338038 100644 --- a/apps/kimi-code/src/tui/utils/searchable-list.ts +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -38,7 +38,7 @@ export interface SearchableListView { } export class SearchableList { - private readonly items: readonly T[]; + private items: readonly T[]; private readonly toSearchText: (item: T) => string; private readonly pageSize: number; private readonly searchable: boolean; @@ -53,6 +53,15 @@ export class SearchableList { this.cursor = Math.max(opts.initialIndex ?? 0, 0); } + /** + * Replaces the item set (e.g. after another page was appended), keeping the + * active query; the cursor is clamped into the new range. + */ + setItems(items: readonly T[]): void { + this.items = items; + this.cursor = Math.min(this.cursor, Math.max(0, items.length - 1)); + } + filtered(): readonly T[] { if (this.query.length === 0) return this.items; return fuzzyFilter([...this.items], this.query, this.toSearchText); diff --git a/apps/kimi-code/src/tui/utils/step-retry.ts b/apps/kimi-code/src/tui/utils/step-retry.ts new file mode 100644 index 00000000000..34a79887863 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/step-retry.ts @@ -0,0 +1,19 @@ +import { RETRY_DETAIL_MAX_CHARS } from '../constant/rendering'; +import type { StepRetryState } from '../types'; + +export function formatStepRetryLabel(retry: StepRetryState): string { + const base = `Retrying (${retry.nextAttempt}/${retry.maxAttempts}) · ${retry.errorName}`; + if (retry.phase === 'attempt') return base; + const delaySeconds = Math.max(1, Math.ceil(retry.delayMs / 1000)); + return `${base} · in ${delaySeconds}s`; +} + +/** Detail line under the spinner: status code + provider message, single-line, capped. */ +export function formatStepRetryDetail(retry: StepRetryState): string { + const message = retry.errorMessage.replaceAll(/\s+/g, ' ').trim(); + const code = retry.statusCode === undefined ? '' : String(retry.statusCode); + const detail = [code, message].filter((part) => part.length > 0).join(' · '); + return detail.length > RETRY_DETAIL_MAX_CHARS + ? `${detail.slice(0, RETRY_DETAIL_MAX_CHARS - 1)}…` + : detail; +} diff --git a/apps/kimi-code/src/utils/git/git-status.ts b/apps/kimi-code/src/utils/git/git-status.ts index c77256f01f4..56b7f0af69f 100644 --- a/apps/kimi-code/src/utils/git/git-status.ts +++ b/apps/kimi-code/src/utils/git/git-status.ts @@ -9,6 +9,8 @@ import { execFile, spawnSync } from 'node:child_process'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; + const BRANCH_TTL_MS = 5_000; const STATUS_TTL_MS = 15_000; const PULL_REQUEST_TTL_MS = 60_000; @@ -67,7 +69,11 @@ export function createGitStatusCache( workDir: string, options: GitStatusCacheOptions = {}, ): GitStatusCache { - const isRepo = detectGitRepo(workDir); + // This cache is constructed before the workspace trust gate, so the git + // binary must be resolved through PATH to an absolute path — a bare name + // would let cmd.exe pick up a `git.exe` planted in the workspace. + const git = resolveCommandPath('git', workDir); + const isRepo = git !== undefined && detectGitRepo(git, workDir); let branch: BranchState = { value: null, fetchedAt: 0 }; let status: StatusState = { dirty: false, @@ -87,16 +93,16 @@ export function createGitStatusCache( return { getStatus: () => { - if (!isRepo) return null; + if (!isRepo || git === undefined) return null; const now = Date.now(); if (now - branch.fetchedAt >= BRANCH_TTL_MS) { - branch = { value: readBranch(workDir), fetchedAt: now }; + branch = { value: readBranch(git, workDir), fetchedAt: now }; } if (branch.value === null) return null; if (now - status.fetchedAt >= STATUS_TTL_MS) { - status = { ...readStatus(workDir), fetchedAt: now }; + status = { ...readStatus(git, workDir), fetchedAt: now }; } refreshPullRequestIfNeeded(branch.value, now); @@ -143,9 +149,9 @@ export function createGitStatusCache( } } -function detectGitRepo(workDir: string): boolean { +function detectGitRepo(git: string, workDir: string): boolean { try { - const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { + const result = spawnSync(git, ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, }); @@ -155,9 +161,9 @@ function detectGitRepo(workDir: string): boolean { } } -function readBranch(workDir: string): string | null { +function readBranch(git: string, workDir: string): string | null { try { - const result = spawnSync('git', ['-C', workDir, 'branch', '--show-current'], { + const result = spawnSync(git, ['-C', workDir, 'branch', '--show-current'], { encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, }); @@ -169,7 +175,10 @@ function readBranch(workDir: string): string | null { } } -function readStatus(workDir: string): { +function readStatus( + git: string, + workDir: string, +): { dirty: boolean; ahead: number; behind: number; @@ -177,7 +186,7 @@ function readStatus(workDir: string): { diffDeleted: number; } { try { - const result = spawnSync('git', ['-C', workDir, 'status', '--porcelain', '-b'], { + const result = spawnSync(git, ['-C', workDir, 'status', '--porcelain', '-b'], { encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, @@ -200,7 +209,7 @@ function readStatus(workDir: string): { dirty = true; } } - const diff = dirty ? readDiffStats(workDir) : { added: 0, deleted: 0 }; + const diff = dirty ? readDiffStats(git, workDir) : { added: 0, deleted: 0 }; return { dirty, ahead, @@ -213,9 +222,9 @@ function readStatus(workDir: string): { } } -function readDiffStats(workDir: string): { added: number; deleted: number } { +function readDiffStats(git: string, workDir: string): { added: number; deleted: number } { try { - const result = spawnSync('git', ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { + const result = spawnSync(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, @@ -244,9 +253,16 @@ function parseDiffNumstatCount(value: string | undefined): number { function readPullRequest(workDir: string): Promise { return new Promise((resolve) => { + // Resolve gh through PATH as well — this runs with cwd = workDir, where a + // planted `gh.exe` would otherwise be picked up by cmd.exe on Windows. + const gh = resolveCommandPath('gh', workDir); + if (gh === undefined) { + resolve(null); + return; + } try { execFile( - 'gh', + gh, ['pr', 'view', '--json', 'number,url'], { cwd: workDir, diff --git a/apps/kimi-code/src/utils/process/fd-detect.ts b/apps/kimi-code/src/utils/process/fd-detect.ts index ed97a0000f3..f41c5d0a68c 100644 --- a/apps/kimi-code/src/utils/process/fd-detect.ts +++ b/apps/kimi-code/src/utils/process/fd-detect.ts @@ -17,6 +17,7 @@ import { pipeline } from 'node:stream/promises'; import { KIMI_CODE_CDN_BASE } from '#/constant/app'; import { getBinDir } from '#/utils/paths'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; const CANDIDATES = ['fd', 'fdfind']; const FD_BASE_URL = `${KIMI_CODE_CDN_BASE}/fd`; @@ -56,9 +57,11 @@ export async function ensureFdPath(): Promise { function detectSystemFdPath(): string | null { for (const name of CANDIDATES) { + const commandPath = resolveCommandPath(name); + if (commandPath === undefined) continue; try { - const result = spawnSync(name, ['--version'], { stdio: 'ignore' }); - if (result.status === 0) return name; + const result = spawnSync(commandPath, ['--version'], { stdio: 'ignore' }); + if (result.status === 0) return commandPath; } catch { // ENOENT, EACCES, etc. — try next candidate. } diff --git a/apps/kimi-code/src/utils/process/resolve-command.ts b/apps/kimi-code/src/utils/process/resolve-command.ts new file mode 100644 index 00000000000..721342e601a --- /dev/null +++ b/apps/kimi-code/src/utils/process/resolve-command.ts @@ -0,0 +1,79 @@ +import { accessSync, constants, statSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; + +// cmd.exe / CreateProcess search the current directory before PATH, so on +// Windows a bare command name can execute a binary planted in the workspace +// the user just opened (binary planting). Resolving through PATH ourselves — +// and refusing any hit inside the cwd — keeps that from happening before the +// workspace trust gate has run. + +const DEFAULT_WIN32_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD']; + +function pathExtensions(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): readonly string[] { + if (platform !== 'win32') return ['']; + const raw = env['PATHEXT']; + if (raw === undefined || raw.trim().length === 0) return DEFAULT_WIN32_PATHEXT; + return raw + .split(';') + .map((ext) => ext.trim()) + .filter((ext) => ext.length > 0); +} + +function candidateNames(command: string, extensions: readonly string[]): readonly string[] { + if (extensions.length === 1 && extensions[0] === '') return [command]; + const lower = command.toLowerCase(); + // An explicitly suffixed name (npm.cmd) is tried as-is first, like cmd.exe. + if (extensions.some((ext) => lower.endsWith(ext.toLowerCase()))) { + return [command, ...extensions.map((ext) => command + ext)]; + } + return extensions.map((ext) => command + ext); +} + +function isExecutableFile(candidate: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(candidate).isFile()) return false; + // Windows has no executable bit; file existence is enough there. + if (platform !== 'win32') accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } +} + +function isInsideCwd(candidate: string, cwd: string, platform: NodeJS.Platform): boolean { + let resolvedCandidate = resolve(candidate); + let resolvedCwd = resolve(cwd); + if (platform === 'win32') { + resolvedCandidate = resolvedCandidate.toLowerCase(); + resolvedCwd = resolvedCwd.toLowerCase(); + } + const rel = relative(resolvedCwd, resolvedCandidate); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} + +/** + * Resolve a bare command name to an absolute executable path by searching + * PATH (PATHEXT-aware on Windows). Returns undefined when the command is not + * found — or when the only hit lives inside `cwd`, since executing that would + * run whatever a malicious workspace planted there. + */ +export function resolveCommandPath(command: string, cwd: string = process.cwd()): string | undefined { + const platform = process.platform; + const env = process.env; + const extensions = pathExtensions(platform, env); + const names = candidateNames(command, extensions); + const pathValue = env['PATH'] ?? ''; + const separator = platform === 'win32' ? ';' : ':'; + for (const dir of pathValue.split(separator)) { + // An empty PATH entry means the current directory on POSIX — anything it + // could produce would be rejected by the cwd check anyway, so skip it. + if (dir === '') continue; + for (const name of names) { + const candidate = join(dir, name); + if (!isExecutableFile(candidate, platform)) continue; + if (isInsideCwd(candidate, cwd, platform)) return undefined; + return resolve(candidate); + } + } + return undefined; +} diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c4e95c1d1b5..73b7a22222d 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -61,7 +61,9 @@ const mocks = vi.hoisted(() => { resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'), flushDiagnosticLogsSync: vi.fn(), harnessCreatesDeviceIdOnConstruction: false, - execSync: vi.fn(), + execFileSync: vi.fn(() => ''), + spawnSync: vi.fn(), + resolveCommandPath: vi.fn(() => '/bin/stty' as string | undefined), TuiConfigParseError, }; }); @@ -132,6 +134,8 @@ vi.mock('../../src/tui/index', () => ({ KimiTUI: class { onExit?: () => Promise; + readonly state = { ui: { mode: 'regular' as const } }; + constructor(...args: unknown[]) { mocks.kimiTuiConstructor(this, ...args); } @@ -152,7 +156,12 @@ vi.mock('../../src/migration/index', () => ({ })); vi.mock('node:child_process', () => ({ - execSync: mocks.execSync, + execFileSync: mocks.execFileSync, + spawnSync: mocks.spawnSync, +})); + +vi.mock('../../src/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, })); describe('runShell', () => { @@ -175,6 +184,7 @@ describe('runShell', () => { mocks.resolveKimiHome.mockImplementation( (homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home', ); + mocks.resolveCommandPath.mockImplementation(() => '/bin/stty'); mocks.harnessCreatesDeviceIdOnConstruction = false; }); @@ -297,7 +307,16 @@ describe('runShell', () => { expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan( mocks.harnessGetConfig.mock.invocationCallOrder[0]!, ); - expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); + // stty is resolved to an absolute path before the trust gate and skipped + // entirely on Windows (a bare `stty` name would resolve into the + // untrusted cwd). + if (process.platform !== 'win32') { + expect(execFileSync).toHaveBeenCalledWith('/bin/stty', ['-ixon'], { + stdio: ['inherit', 'ignore', 'ignore'], + }); + } else { + expect(execFileSync).not.toHaveBeenCalled(); + } expect(mocks.kimiTuiConstructor).toHaveBeenCalledTimes(1); expect(mocks.createKimiDeviceId).toHaveBeenCalledWith( '/tmp/kimi-code-test-home', @@ -336,9 +355,31 @@ describe('runShell', () => { config_ms: expect.any(Number), init_ms: expect.any(Number), mcp_ms: 47, + tui_mode: 'regular', }); }); + it('never runs stty on Windows, where it would resolve into the untrusted cwd', async () => { + stubTuiStartup(); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + await runShell(minimalCliOptions, '1.2.3-test'); + expect(execFileSync).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('skips stty when it cannot be resolved outside the untrusted cwd', async () => { + stubTuiStartup(); + if (process.platform === 'win32') return; + mocks.resolveCommandPath.mockReturnValue(undefined); + await runShell(minimalCliOptions, '1.2.3-test'); + expect(mocks.resolveCommandPath).toHaveBeenCalledWith('stty'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + it('resolves the --agent profile into the TUI startup input', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', @@ -522,6 +563,7 @@ describe('runShell', () => { config_ms: expect.any(Number), init_ms: expect.any(Number), mcp_ms: 47, + tui_mode: 'regular', }); }); @@ -779,7 +821,10 @@ describe('runShell', () => { ).rejects.toThrow('boom'); expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); - expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_ms: expect.any(Number) }); + expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { + duration_ms: expect.any(Number), + tui_mode: 'regular', + }); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); expect(mocks.harnessClose).toHaveBeenCalledOnce(); }); @@ -828,6 +873,7 @@ describe('runShell', () => { expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-1' }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('exit', { duration_ms: expect.any(Number), + tui_mode: 'regular', }); expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything()); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2d1f373fdeb..e49ec87f578 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -37,6 +37,13 @@ const mocks = vi.hoisted(() => ({ resolveUpdateDeviceId: vi.fn(), appendRolloutDecisionLog: vi.fn(), spawn: vi.fn(), + // Identity by default: resolution is covered by resolve-command.test.ts; + // here we only care which command string reaches spawn(). + resolveCommandPath: vi.fn((cmd: string) => cmd as string | undefined), +})); + +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, })); vi.mock('../../../src/cli/update/cache', () => ({ @@ -240,6 +247,7 @@ describe('runUpdatePreflight', () => { filePath: '/tmp/kimi-update-install.lock', release: vi.fn().mockResolvedValue(undefined), }); + mocks.resolveCommandPath.mockImplementation((cmd: string) => cmd); }); afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); }); @@ -437,7 +445,8 @@ describe('runUpdatePreflight', () => { const { options } = captureOutput(); await runUpdatePreflight('0.4.0', options); expect(mocks.spawn).toHaveBeenCalledWith( - 'pnpm.cmd', + // Resolved to an absolute path and quoted for the cmd.exe shell. + '"pnpm.cmd"', ['add', '-g', '@mbuckaway/kimi-code@0.5.0'], { stdio: 'inherit', shell: true }, ); @@ -576,6 +585,66 @@ describe('runUpdatePreflight', () => { expect(stdout.join('')).not.toContain('Updated @mbuckaway/kimi-code'); }); + it('spawns the resolved absolute path instead of the bare command name', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mocks.resolveCommandPath.mockReturnValue('/usr/local/bin/npm'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + + expect(mocks.resolveCommandPath).toHaveBeenCalledWith('npm'); + expect(mocks.spawn).toHaveBeenCalledWith( + '/usr/local/bin/npm', + ['install', '-g', '@mbuckaway/kimi-code@0.5.0'], + { stdio: 'inherit' }, + ); + }); + + it('warns and continues without spawning when the package manager cannot be resolved', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + // Only resolvable inside the cwd (or missing entirely): refuse to run it. + mocks.resolveCommandPath.mockReturnValue(undefined); + const { stdout, stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(stderr.join('')).toContain('warning: failed to install'); + expect(stdout.join('')).not.toContain('Updated @moonshot-ai/kimi-code'); + }); + + it('records a background install failure without spawning when the package manager cannot be resolved', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.resolveCommandPath.mockReturnValue(undefined); + const { stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(stderr.join('')).toBe(''); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + }), + lastSuccess: null, + })); + }); + it('starts an automatic update in the background by default', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState()); @@ -625,7 +694,8 @@ describe('runUpdatePreflight', () => { const { options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); expect(mocks.spawn).toHaveBeenCalledWith( - 'npm.cmd', + // Resolved to an absolute path and quoted for the cmd.exe shell. + '"npm.cmd"', ['install', '-g', '@mbuckaway/kimi-code@0.5.0'], { detached: true, stdio: 'ignore', shell: true, windowsHide: true }, ); diff --git a/apps/kimi-code/test/cli/update/source.test.ts b/apps/kimi-code/test/cli/update/source.test.ts index 76a350ce5a2..33d470a2bf3 100644 --- a/apps/kimi-code/test/cli/update/source.test.ts +++ b/apps/kimi-code/test/cli/update/source.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { classifyByPathHeuristic, classifyInstallSource, detectInstallSource, } from '#/cli/update/source'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; + +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: vi.fn(), +})); describe('classifyByPathHeuristic', () => { it('returns null for an npm-style global path (handled by classifyInstallSource)', () => { @@ -176,4 +181,19 @@ describe('detectInstallSource', () => { }), ).resolves.toBe('unsupported'); }); + + it('returns unsupported when npm cannot be resolved outside the cwd', async () => { + // The default prefix lookup spawns npm; when it can only be found inside + // the current directory (or not at all), detection must degrade to + // 'unsupported' rather than run a planted binary. + vi.mocked(resolveCommandPath).mockReturnValue(undefined); + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/dev/@moonshot-ai/kimi-code', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('unsupported'); + expect(resolveCommandPath).toHaveBeenCalledWith('npm'); + }); }); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 8ea1370add3..aed97130be3 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -182,7 +182,7 @@ describe('built-in slash command registry', () => { 'plan', 'reload', 'reload-tui', - 'secondary_model', + 'secondary-model', 'sessions', 'settings', 'status', @@ -207,8 +207,8 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); }); - it('gates secondary_model behind the secondary-model experiment, always available', () => { - const command = findBuiltInSlashCommand('secondary_model'); + it('gates secondary-model behind the secondary-model experiment, always available', () => { + const command = findBuiltInSlashCommand('secondary-model'); expect(command).toBeDefined(); expect((command as KimiSlashCommand).experimentalFlag).toBe('secondary-model'); expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index b36f96213ab..e0d9352401e 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -14,6 +14,10 @@ import { isExperimentalFlagEnabled, setExperimentalFeatures, } from '#/tui/commands/experimental-flags'; +import { + createMarkdownOptions, + setMarkdownRenderLatex, +} from '#/tui/utils/markdown-options'; const tempDirs: string[] = []; const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; @@ -116,6 +120,27 @@ auto_install = false expect(themeWhenTracked).toBe('auto'); }); + it('applies the render_latex toggle before theme application rebuilds Markdown', async () => { + await writeTuiConfig('render_latex = false\n'); + const host = makeHost(); + + // applyTheme invalidates transcript components, which rebuild their + // Markdown children by copying the shared options — the reloaded value + // must already be live at that point. + let latexWhenThemeApplied: boolean | undefined; + const mutable = host as unknown as { applyTheme: unknown }; + mutable.applyTheme = vi.fn(() => { + latexWhenThemeApplied = createMarkdownOptions().renderLatex; + }); + + try { + await handleReloadTuiCommand(host); + expect(latexWhenThemeApplied).toBe(false); + } finally { + setMarkdownRenderLatex(true); + } + }); + it('refreshes workspace commands and lazy defaults on a session-less v2 reload', async () => { await writeTuiConfig('theme = "dark"\n'); const host = makeHost(); diff --git a/apps/kimi-code/test/tui/commands/secondary-model.test.ts b/apps/kimi-code/test/tui/commands/secondary-model.test.ts index 81b309ef013..9bce58d4c0f 100644 --- a/apps/kimi-code/test/tui/commands/secondary-model.test.ts +++ b/apps/kimi-code/test/tui/commands/secondary-model.test.ts @@ -1,10 +1,11 @@ /** - * Scenario: /secondary_model command behavior in the interactive TUI. - * Responsibilities: picker filtering, persistence, live apply, and effective-model state refresh. + * Scenario: /secondary-model command behavior in the interactive TUI. + * Responsibilities: picker filtering, persistence of `[secondary_model] default_model` + * (keeping existing pool descriptions), and error paths. * Wiring: real command and selector with the SDK/session boundaries stubbed by a small host rig. * Run: pnpm -C apps/kimi-code exec vitest run test/tui/commands/secondary-model.test.ts */ -import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; import type { SlashCommandHost } from '#/tui/commands'; @@ -14,9 +15,10 @@ import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-mo interface PickerOptions { readonly models: Record; readonly currentValue: string; - readonly currentThinkingEffort: string; + readonly selectedValue?: string; readonly title?: string; - readonly onSelect: (selection: { alias: string; thinking: ThinkingEffort }) => void; + readonly thinkingControl?: boolean; + readonly onSelect: (selection: { alias: string }) => void; } function model(name: string): ModelAlias { @@ -29,21 +31,16 @@ function model(name: string): ModelAlias { } function makeHost(options?: { - readonly withSession?: boolean; - readonly secondaryModel?: { model: string; defaultEffort?: string }; - readonly persistedModels?: Record; - /** The secondary model the reloaded config carries — env overlays win. */ - readonly effectiveSecondary?: { model: string; defaultEffort?: string }; + readonly secondaryModel?: { defaultModel?: string; models?: Record }; }) { - const session = options?.withSession === false - ? undefined - : { applyPersistedSecondaryModel: vi.fn(async () => {}) }; const appState = { availableModels: { k2: model('k2'), cheap: model('cheap'), - // The synthesized derived entry must never be selectable. + // The v1 derived entry must never be selectable. '__secondary__': model('cheap'), + // The pool's reserved symbolic choice must never be selectable either. + 'primary': model('primary'), } as Record, availableProviders: {}, transcriptEntries: [], @@ -61,13 +58,8 @@ function makeHost(options?: { providers: {}, secondaryModel: options?.secondaryModel, })), - setConfig: vi.fn(async () => ({ - providers: {}, - models: options?.persistedModels, - secondaryModel: options?.effectiveSecondary, - })), + setConfig: vi.fn(async () => ({})), }, - session, setAppState: vi.fn((patch) => Object.assign(appState, patch)), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), @@ -85,7 +77,7 @@ function makeHost(options?: { showError: ReturnType; showNotice: ReturnType; }; - return { host, session }; + return { host }; } function mountedPicker(host: { mountEditorReplacement: ReturnType }): PickerOptions { @@ -96,108 +88,86 @@ function mountedPicker(host: { mountEditorReplacement: ReturnType } describe('handleSecondaryModelCommand', () => { - it('opens the picker filtered to user models, with the configured recipe as current', async () => { - const { host } = makeHost({ secondaryModel: { model: 'cheap', defaultEffort: 'high' } }); + it('opens the picker filtered to user models, with the configured default as current', async () => { + const { host } = makeHost({ secondaryModel: { defaultModel: 'cheap' } }); await handleSecondaryModelCommand(host, ''); const opts = mountedPicker(host); expect(Object.keys(opts.models)).toEqual(['k2', 'cheap']); expect(opts.currentValue).toBe('cheap'); - expect(opts.currentThinkingEffort).toBe('high'); expect(opts.title).toContain('secondary model'); + // Pool bindings carry no explicit thinking level — the picker hides the + // Thinking footer instead of offering a no-op choice. + expect(opts.thinkingControl).toBe(false); }); - it('persists first, then live-applies the selection to the session', async () => { - const { host, session } = makeHost(); + it('persists only default_model when no pool exists (implicit single-entry pool)', async () => { + const { host } = makeHost(); await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + mountedPicker(host).onSelect({ alias: 'k2' }); await vi.waitFor(() => { expect(host.showStatus).toHaveBeenCalled(); }); expect(host.harness.setConfig).toHaveBeenCalledWith({ - secondaryModel: { model: 'k2', defaultEffort: 'high' }, + secondaryModel: { defaultModel: 'k2' }, }); - expect(session!.applyPersistedSecondaryModel).toHaveBeenCalledWith(); - expect(host.harness.setConfig.mock.invocationCallOrder[0]).toBeLessThan( - session!.applyPersistedSecondaryModel.mock.invocationCallOrder[0]!, - ); expect(host.showError).not.toHaveBeenCalled(); }); - it('refreshes the effective model map after a live secondary-model switch', async () => { + it('adds the picked alias to an existing pool with an empty description', async () => { const { host } = makeHost({ - persistedModels: { - k2: model('k2'), - cheap: model('cheap'), - '__secondary__': model('k2'), + secondaryModel: { + defaultModel: 'cheap', + models: { cheap: 'fast and cheap' }, }, }); await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + mountedPicker(host).onSelect({ alias: 'k2' }); await vi.waitFor(() => { expect(host.showStatus).toHaveBeenCalled(); }); - expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('k2'); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { + defaultModel: 'k2', + models: { cheap: 'fast and cheap', k2: '' }, + }, + }); }); - it('warns with the env-overridden effective binding instead of the picked model', async () => { - // KIMI_SECONDARY_MODEL / KIMI_SECONDARY_EFFORT win over the persisted - // recipe: the reloaded config carries the overlaid values, and the status - // message must name them rather than echo the pick. + it('keeps existing pool descriptions and other pool entries on save', async () => { const { host } = makeHost({ - effectiveSecondary: { model: 'cheap', defaultEffort: 'low' }, + secondaryModel: { + defaultModel: 'cheap', + models: { cheap: 'fast and cheap', k2: 'hard tasks' }, + }, }); await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); + mountedPicker(host).onSelect({ alias: 'k2' }); await vi.waitFor(() => { expect(host.showStatus).toHaveBeenCalled(); }); - const [message, color] = host.showStatus.mock.calls[0]!; - expect(message).toContain('KIMI_SECONDARY_MODEL=cheap'); - expect(message).toContain('KIMI_SECONDARY_EFFORT=low'); - expect(color).toBe('warning'); - expect(host.showError).not.toHaveBeenCalled(); - }); - - it('keeps the current effective model map when live apply fails', async () => { - const { host, session } = makeHost({ - persistedModels: { - k2: model('k2'), - cheap: model('cheap'), - '__secondary__': model('k2'), + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { + defaultModel: 'k2', + models: { cheap: 'fast and cheap', k2: 'hard tasks' }, }, }); - session!.applyPersistedSecondaryModel.mockRejectedValueOnce(new Error('apply failed')); - - await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'high' }); - - await vi.waitFor(() => { - expect(host.showError).toHaveBeenCalled(); - }); - expect(host.state.appState.availableModels['__secondary__']?.displayName).toBe('cheap'); }); - it('persists only when there is no session', async () => { - const { host } = makeHost({ withSession: false }); + it('pre-selects a valid alias argument instead of erroring', async () => { + const { host } = makeHost(); - await handleSecondaryModelCommand(host, ''); - mountedPicker(host).onSelect({ alias: 'k2', thinking: 'off' }); + await handleSecondaryModelCommand(host, 'cheap'); - await vi.waitFor(() => { - expect(host.showStatus).toHaveBeenCalled(); - }); - expect(host.harness.setConfig).toHaveBeenCalledWith({ - secondaryModel: { model: 'k2', defaultEffort: 'off' }, - }); - expect(host.showStatus.mock.calls[0]![0]).toContain('new sessions'); + const opts = mountedPicker(host); + expect(opts.selectedValue).toBe('cheap'); }); it('rejects an unknown alias argument without opening the picker', async () => { @@ -218,6 +188,26 @@ describe('handleSecondaryModelCommand', () => { expect(host.mountEditorReplacement).not.toHaveBeenCalled(); }); + it('rejects the reserved primary alias as an argument', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, 'primary'); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('reserved')); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('reports the reserved error for primary even when it is the only configured model', async () => { + const { host } = makeHost(); + host.state.appState.availableModels = { primary: model('primary') }; + + await handleSecondaryModelCommand(host, 'primary'); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('reserved')); + expect(host.showNotice).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + it('shows a notice when no models are configured', async () => { const { host } = makeHost(); host.state.appState.availableModels = {}; @@ -227,4 +217,18 @@ describe('handleSecondaryModelCommand', () => { expect(host.showNotice).toHaveBeenCalled(); expect(host.mountEditorReplacement).not.toHaveBeenCalled(); }); + + it('reports a persistence failure without a status message', async () => { + const { host } = makeHost(); + host.harness.setConfig.mockRejectedValueOnce(new Error('disk full')); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2' }); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalled(); + }); + expect(host.showError.mock.calls[0]![0]).toContain('disk full'); + expect(host.showStatus).not.toHaveBeenCalled(); + }); }); diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index bf56ba018ec..8e79bfe9102 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -43,6 +43,7 @@ describe('update preference commands', () => { theme: 'auto', editorCommand: null, disablePasteBurst: false, + renderLatex: true, cacheExpiryHint: true, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, @@ -52,4 +53,29 @@ describe('update preference commands', () => { expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false }); expect(showStatus).toHaveBeenCalledWith('Automatic updates disabled.'); }); + + it('preserves a render_latex opt-out when saving an unrelated preference', async () => { + mocks.saveTuiConfig.mockClear(); + const host = { + state: { + appState: { + theme: 'auto' as const, + editorCommand: null, + renderLatex: false, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + }, + theme: { palette: darkColors }, + }, + setAppState: vi.fn(), + showStatus: vi.fn(), + track: vi.fn(), + }; + + await applyUpdatePreferenceChoice(host, false); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ renderLatex: false }), + ); + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts index aecf815d98d..1d2d5034a2a 100644 --- a/apps/kimi-code/test/tui/components/chrome/banner.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -182,7 +182,7 @@ describe('BannerComponent', () => { }); it('keeps subsequent main lines indented to the main-text column and subtext aligned with the tag text', () => { - const width = 20; + const width = 24; const lines = new BannerComponent( makeBannerState({ tag: 'New:', @@ -196,9 +196,47 @@ describe('BannerComponent', () => { expect(lines[0]).toContain('✦ New:'); const firstLine = lines[0]!; const mainTextStart = visibleWidth(firstLine.slice(0, firstLine.indexOf('Line 1'))); - const continuationLine = lines.find((line) => line.includes('lot of'))!; - expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('lot of')))).toBe(mainTextStart); + const continuationLine = lines.find((line) => line.includes('of content'))!; + expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('of content')))).toBe(mainTextStart); const subLine = lines.find((line) => line.includes('Sub text'))!; expect(visibleWidth(subLine.slice(0, subLine.indexOf('Sub text')))).toBe(visibleWidth('✦ ')); }); + + it('moves a long tag onto its own line so the main text keeps a usable width', () => { + // Regression: remote banner configs can set a full-sentence tag. Inline it + // would leave the main text only a few columns, which hard-breaks words. + const width = 50; + const lines = new BannerComponent( + makeBannerState({ + tag: 'Use Kimi K3 with High thinking effort', + mainText: '- for the best balance between token spend and capability', + subText: 'Run /model to switch to K3 and set thinking effort to High', + }), + ).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + // The tag occupies the first line alone; no main text is squeezed next to it. + expect(lines[0]).toContain('✦ Use Kimi K3 with High thinking effort'); + expect(lines[0]).not.toContain('- for'); + // Words stay intact (no mid-word hard breaks like "balan"/"ce"). + const joined = lines.join('\n'); + for (const word of ['balance', 'between', 'capability', 'thinking', 'effort']) { + expect(joined).toContain(word); + } + // Main text and subtext align with the tag text (right after "✦ "). + const mainLine = lines.find((line) => line.includes('- for'))!; + expect(visibleWidth(mainLine.slice(0, mainLine.indexOf('- for')))).toBe(visibleWidth('✦ ')); + const subLine = lines.find((line) => line.includes('Run /model'))!; + expect(visibleWidth(subLine.slice(0, subLine.indexOf('Run /model')))).toBe(visibleWidth('✦ ')); + }); + + it('keeps a short tag inline when the remaining width is enough', () => { + const width = 40; + const lines = new BannerComponent( + makeBannerState({ tag: 'Tip:', mainText: 'Use /help to list commands.' }), + ).render(width); + expect(lines[0]).toContain('✦ Tip:'); + expect(lines[0]).toContain('Use /help'); + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts index a9009122565..7a3cb7ce82a 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -29,6 +29,7 @@ const baseState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 8d7e6f83c1f..f8ad19dbc77 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -48,6 +48,7 @@ const appState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts index 295363a74d8..e62c4c25adc 100644 --- a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts @@ -54,4 +54,15 @@ describe('GutterContainer', () => { c.addChild(new FakeChild(() => [colored])); expect(c.render(20)).toEqual([` ${colored}`]); }); + + it('keeps a leading OSC 133 zone marker at byte 0, before the gutter', () => { + const c = new GutterContainer(2, 2); + const marked = `\x1b]133;A\x07content`; + const doubleMarked = `\x1b]133;B\x07\x1b]133;C\x07last`; + c.addChild(new FakeChild(() => [marked, doubleMarked])); + expect(c.render(20)).toEqual([ + `\x1b]133;A\x07 content`, + `\x1b]133;B\x07\x1b]133;C\x07 last`, + ]); + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index c7eeae5fd66..a977d6e6625 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -25,6 +25,7 @@ const appState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts b/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts new file mode 100644 index 00000000000..a6908b919f5 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts @@ -0,0 +1,315 @@ +import type { Terminal } from '@moonshot-ai/pi-tui'; +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { AgentActivityViewer, formatSubagentActivityPreview } from '#/tui/components/dialogs/agent-activity-viewer'; +import type { SubagentActivityRecord } from '#/tui/controllers/subagent-activity-store'; + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +/** Kitty CSI-u form of Ctrl+O (codepoint 111, modifier 1+4). */ +const CTRL_O = '\u001B[111;5u'; + +/** Minimal Terminal stub — only `rows` is read by the component. */ +function fakeTerminal(rows: number, columns = 120): Terminal { + return { + start: () => {}, + stop: () => {}, + drainInput: () => Promise.resolve(), + write: () => {}, + get columns() { + return columns; + }, + get rows() { + return rows; + }, + get kittyProtocolActive() { + return false; + }, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; +} + +function agentTask(overrides: Record = {}): BackgroundTaskInfo { + return { + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent-1', + description: 'find things', + status: 'running', + startedAt: Date.now() - 60_000, + endedAt: null, + ...overrides, + } as BackgroundTaskInfo; +} + +function record(overrides: Partial = {}): SubagentActivityRecord { + return { + agentId: 'agent-1', + agentName: 'explore', + description: 'find things', + parentToolCallId: 'tc-1', + steps: [], + totalSteps: 0, + status: 'running', + version: 1, + ...overrides, + }; +} + +function makeViewer( + props: Partial[0]> & { + record?: SubagentActivityRecord; + } = {}, + rows = 20, + columns = 80, +): AgentActivityViewer { + return new AgentActivityViewer( + { + taskId: 'agent-task-1', + info: agentTask(), + record: props.record, + onClose: vi.fn(), + ...props, + }, + fakeTerminal(rows, columns), + ); +} + +function renderPlain(viewer: AgentActivityViewer, width = 80): string { + return strip(viewer.render(width).join('\n')); +} + +describe('AgentActivityViewer', () => { + it('fills exactly terminal.rows lines', () => { + const viewer = makeViewer({}, 20); + expect(viewer.render(80).length).toBe(20); + }); + + it('shows agent label, status and step range in the header', () => { + const viewer = makeViewer({ + record: record({ + steps: [ + { step: 8, textTail: '', toolCalls: [] }, + { step: 9, textTail: '', toolCalls: [] }, + ], + totalSteps: 12, + }), + }); + const text = renderPlain(viewer, 120); + expect(text).toContain('Agent activity'); + expect(text).toContain('explore › find things'); + expect(text).toContain('running'); + expect(text).toContain('step 8–9 / 12'); + expect(text).toContain('earlier steps discarded'); + }); + + it('renders steps with tool call headers and result renderer output', () => { + const viewer = makeViewer({ + record: record({ + steps: [ + { + step: 0, + textTail: 'Looking for the event bus definition.', + toolCalls: [ + { + id: 't1', + name: 'Grep', + args: { pattern: 'IEventBus' }, + status: 'done', + startedAt: 0, + result: { + tool_call_id: 't1', + output: 'src/a.ts:1:IEventBus\nsrc/b.ts:2:IEventBus', + is_error: false, + }, + }, + ], + }, + ], + totalSteps: 1, + }), + }); + const text = renderPlain(viewer); + expect(text).toContain('── step 0 ──'); + expect(text).toContain('Looking for the event bus definition.'); + expect(text).toContain('Used Grep (IEventBus) · 2 matches'); + // grep glance renderer: path samples below the header (`path:line` form) + expect(text).toContain('src/a.ts:1, src/b.ts:2'); + }); + + it('collapses long output by default and expands it with ctrl+o', () => { + const longOutput = Array.from({ length: 10 }, (_, i) => `line ${String(i + 1)}`).join('\n'); + const makeRecord = (): SubagentActivityRecord => + record({ + steps: [ + { + step: 0, + textTail: '', + toolCalls: [ + { + id: 't1', + name: 'Bash', + args: { command: 'ls' }, + status: 'done', + startedAt: 0, + result: { tool_call_id: 't1', output: longOutput, is_error: false }, + }, + ], + }, + ], + totalSteps: 1, + }); + + const collapsed = makeViewer({ record: makeRecord() }); + const collapsedText = renderPlain(collapsed); + expect(collapsedText).toContain('ctrl+o to expand'); + expect(collapsedText).not.toContain('line 10'); + + collapsed.handleInput(CTRL_O); + const expandedText = renderPlain(collapsed); + expect(expandedText).toContain('line 10'); + }); + + it('opens pinned to the latest activity and keeps scroll position when the user scrolled up', () => { + const steps = Array.from({ length: 8 }, (_, i) => ({ + step: i, + textTail: `step ${String(i)} text`, + toolCalls: [], + })); + const rec = record({ steps, totalSteps: 8 }); + const viewer = makeViewer({ record: rec }, 12); + + // Initial render follows the tail: the last step is visible. + expect(renderPlain(viewer)).toContain('step 7 text'); + + // User scrolls to the top, then new activity arrives (version bump): + // the view must stay where the user parked it. + viewer.handleInput('g'); + expect(renderPlain(viewer)).toContain('step 0 text'); + rec.steps.push({ step: 8, textTail: 'step 8 text', toolCalls: [] }); + rec.version += 1; + viewer.setProps({ taskId: 'agent-task-1', info: agentTask(), record: rec, onClose: vi.fn() }); + const after = renderPlain(viewer); + expect(after).toContain('step 0 text'); + expect(after).not.toContain('step 8 text'); + }); + + it('shows an explicit empty state when no record exists', () => { + const viewer = makeViewer({ record: undefined }); + expect(renderPlain(viewer)).toContain('[no activity recorded]'); + }); + + it('renders the terminal result summary section', () => { + const viewer = makeViewer({ + info: agentTask({ status: 'completed' }), + record: record({ status: 'completed', resultSummary: 'Found 3 call sites.' }), + }); + const text = renderPlain(viewer); + expect(text).toContain('completed'); + expect(text).toContain('Result'); + expect(text).toContain('Found 3 call sites.'); + }); + + it('closes on q and escape', () => { + const onClose = vi.fn(); + const viewer = makeViewer({ record: record(), onClose }); + viewer.handleInput('q'); + expect(onClose).toHaveBeenCalledTimes(1); + viewer.handleInput('\u001B'); + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); + +describe('formatSubagentActivityPreview', () => { + it('renders steps, tool calls and the terminal result as plain text', () => { + const text = formatSubagentActivityPreview( + record({ + status: 'completed', + resultSummary: 'Found 3 call sites.', + totalSteps: 1, + steps: [ + { + step: 0, + textTail: 'Looking around.', + toolCalls: [ + { + id: 't1', + name: 'Grep', + args: { pattern: 'IEventBus' }, + status: 'done', + startedAt: 0, + result: { + tool_call_id: 't1', + output: 'src/a.ts:1:IEventBus\nsrc/b.ts:2:IEventBus', + is_error: false, + }, + }, + { + id: 't2', + name: 'Read', + args: { path: '/repo/src/a.ts' }, + status: 'running', + startedAt: 0, + liveOutputTail: 'reading…', + }, + ], + }, + ], + }), + ); + expect(text).toContain('── step 0 ──'); + expect(text).toContain('Looking around.'); + expect(text).toContain('✓ Used Grep (IEventBus) · 2 matches'); + expect(text).toContain('● Using Read (/repo/src/a.ts)'); + expect(text).toContain('│ reading…'); // live tail for the in-flight call + expect(text).toContain('Result:'); + expect(text).toContain('Found 3 call sites.'); + // The preview frame styles whole lines itself — the preview stays ANSI-free. + expect(text).not.toMatch(/\[[0-9;]*m/); + }); + + it('shows the live output tail for a running call', () => { + const text = formatSubagentActivityPreview( + record({ + totalSteps: 1, + steps: [ + { + step: 0, + textTail: '', + toolCalls: [ + { + id: 't1', + name: 'Bash', + args: { command: 'pnpm test' }, + status: 'running', + startedAt: 0, + liveOutputTail: '42 passing', + }, + ], + }, + ], + }), + ); + expect(text).toContain('● Using Bash (pnpm test)'); + expect(text).toContain('│ 42 passing'); + }); + + it('returns a waiting placeholder for a fresh running record', () => { + expect(formatSubagentActivityPreview(record())).toBe('Waiting for activity…'); + }); + + it('returns an empty string for a terminal record without any activity', () => { + expect(formatSubagentActivityPreview(record({ status: 'failed' }))).toBe(''); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 8fced417684..e5159ec0d92 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -99,6 +99,39 @@ describe('ModelSelectorComponent', () => { expect(text(picker)).toContain('Thinking (←→ to switch)'); }); + it('hides the Thinking footer when thinkingControl is false', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + thinkingControl: false, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(text(picker)).not.toContain('Thinking'); + }); + + it('ignores Left/Right when thinkingControl is false', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2', ['thinking']) }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + thinkingControl: false, + onSelect, + onCancel: vi.fn(), + }); + + // Same setup as the toggle test above: either arrow would flip 'on' to 'off'. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); + }); + it('forces always-thinking models on and unsupported models off', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 812ac0d94b1..322d10e9135 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -394,6 +394,42 @@ describe('QuestionDialogComponent', () => { expect(out).toContain('Mushroom'); }); + it('multi-select Other can be toggled off after it is committed', () => { + const pending = makePending([ + { + question: 'Pick toppings?', + multi_select: true, + options: [{ label: 'Cheese' }, { label: 'Pepperoni' }], + }, + ]); + const { dialog, collected } = makeDialog(pending); + + // Select Other and commit a custom value. + dialog.handleInput('3'); + dialog.handleInput('M'); + dialog.handleInput('u'); + dialog.handleInput('s'); + dialog.handleInput('h'); + dialog.handleInput('r'); + dialog.handleInput('o'); + dialog.handleInput('o'); + dialog.handleInput('m'); + dialog.handleInput('\r'); + + // Toggle it off using the same key. + dialog.handleInput('3'); + // Select a preset option to confirm the answer still builds correctly. + dialog.handleInput('1'); + dialog.handleInput('\t'); + + const review = strip(dialog.render(80).join('\n')); + expect(review).toContain('Cheese'); + expect(review).not.toContain('Mushroom'); + + dialog.handleInput('1'); + expect(collected).toEqual([['Cheese']]); + }); + it('escape dismisses with empty answers array', () => { const pending = makePending([ { diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 3c885488b25..222adfa6a59 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -709,4 +709,164 @@ describe('SessionPickerComponent', () => { expect(onToggleScope).toHaveBeenCalledOnce(); expect(onToggleScope).toHaveBeenCalledWith('ses_beta'); }); + + it('fires onLoadMore when the cursor reaches the last fetched row', () => { + const onLoadMore = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onLoadMore, + }); + + component.handleInput('\u001B[B'); + + expect(onLoadMore).toHaveBeenCalledOnce(); + }); + + it('does not fire onLoadMore while a page fetch is in flight', () => { + const onLoadMore = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + loadingMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onLoadMore, + }); + + component.handleInput('\u001B[B'); + + expect(onLoadMore).not.toHaveBeenCalled(); + }); + + it('appendSessions extends the list and keeps the active query', () => { + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('g'); + expect(renderPlain(component)).toContain('No matches'); + + component.appendSessions([ + { id: 'ses_gamma', title: 'Gamma session', work_dir: '/tmp/p', updated_at: 2 }, + ]); + + const output = renderPlain(component); + expect(output).toContain('Search: g'); + expect(output).toContain('Gamma session'); + expect(output).not.toContain('Alpha session'); + }); + + it('appendSessions keeps the selected row', () => { + const onSelect = vi.fn(); + const beta = { id: 'ses_beta', title: 'Beta session', work_dir: '/tmp/p', updated_at: 2 }; + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }, + beta, + ], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('\u001B[B'); + component.appendSessions([ + { id: 'ses_gamma', title: 'Gamma session', work_dir: '/tmp/p', updated_at: 3 }, + ]); + component.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(beta); + }); + + it('fires onSearchDrain only when the query becomes active with unfetched pages', () => { + const onSearchDrain = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onSearchDrain, + }); + + component.handleInput('a'); + component.handleInput('l'); + + expect(onSearchDrain).toHaveBeenCalledOnce(); + }); + + it('does not fire onSearchDrain when every page is already fetched', () => { + const onSearchDrain = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onSearchDrain, + }); + + component.handleInput('a'); + + expect(onSearchDrain).not.toHaveBeenCalled(); + }); + + it('announces unfetched pages and in-flight fetches in the footer', () => { + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(renderPlain(component)).toContain('· scroll for more'); + + component.setPaging(true, true); + expect(renderPlain(component)).toContain('· loading more…'); + + component.setPaging(false, false); + const settled = renderPlain(component); + expect(settled).not.toContain('· scroll for more'); + expect(settled).not.toContain('· loading more…'); + }); + + it('notes the background drain in the footer while searching with unfetched pages', () => { + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('a'); + + expect(renderPlain(component)).toContain('· searching all…'); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index f6ffc649613..c202e0bf857 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -139,12 +139,12 @@ describe('TabbedModelSelectorComponent', () => { models: { k2: model('Kimi K2', 'managed:kimi-code') }, currentValue: 'k2', currentThinkingEffort: 'off', - title: ' Select a secondary model (subagents)', + title: ' Choose a model for this task', onSelect: vi.fn(), onCancel: vi.fn(), }); const out = strip(titled.render(120).join('\n')); - expect(out).toContain('Select a secondary model (subagents)'); + expect(out).toContain('Choose a model for this task'); expect(out).not.toContain('Select a model '); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts b/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts index 389b211493a..4fe7d5361dd 100644 --- a/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import type { WorkspaceTrustMcpServerInfo } from '@moonshot-ai/kimi-code-sdk'; + import { TrustPromptComponent } from '#/tui/components/dialogs/trust-prompt'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -8,7 +10,7 @@ function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } -function renderLines(gatedMcpServers: readonly string[] = []): string[] { +function renderLines(gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[] = []): string[] { const prompt = new TrustPromptComponent({ workDir: '/tmp/demo-workspace', gatedMcpServers, @@ -30,20 +32,48 @@ describe('TrustPromptComponent', () => { }); it('lists the gated project MCP servers when present', () => { - const lines = renderLines(['nested-server', 'root-server']); - expect(lines.some((l) => l.includes('This folder defines'))).toBe(true); - expect(lines.some((l) => l.includes('nested-server'))).toBe(true); - expect(lines.some((l) => l.includes('root-server'))).toBe(true); + const lines = renderLines([ + { name: 'nested-server', transport: 'stdio', command: 'nested-cmd', args: ['--safe'], cwd: '/tmp' }, + { name: 'root-server', transport: 'http', url: 'https://example.test/mcp' }, + ]); + expect(lines.some((l) => l.includes('Project MCP targets'))).toBe(true); + expect(lines.some((l) => l.includes('nested-server (stdio): command=nested-cmd'))).toBe(true); + expect(lines.some((l) => l.includes('args=["--safe"] cwd=/tmp'))).toBe(true); + expect(lines.some((l) => l.includes('root-server (http): url=https://example.test/mcp'))).toBe(true); expect(renderLines().some((l) => l.includes('This folder defines'))).toBe(false); }); - it('selects trust on Enter with the default highlight', () => { + it('strips terminal control characters from workspace-supplied MCP targets', () => { + const lines = renderLines([ + { name: 'evil', transport: 'stdio', command: 'cmd\u001B[2J\u0007evil' }, + { name: 'multi\nline', transport: 'http', url: 'https://example.test/\u001B]8;;https://evil.test\u0007' }, + ]); + const text = lines.join('\n'); + // ESC and BEL are dropped, defusing the sequences into harmless literal text. + expect(text).toContain('evil (stdio): command=cmd[2Jevil'); + expect(text).toContain('multiline (http): url=https://example.test/]8;;https://evil.test'); + expect(text).not.toContain('\u001B]8;;https://evil.test'); + }); + + it("defaults to Don't trust", () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('distrust'); + }); + + it('selects trust only after moving to it explicitly', () => { const onSelect = vi.fn(); const prompt = new TrustPromptComponent({ workDir: '/tmp/demo-workspace', gatedMcpServers: [], onSelect, }); + prompt.handleInput('\u001B[A'); prompt.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith('trust'); }); diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index f66c92e7d62..a2755d2ea0e 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -516,8 +516,6 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toContain('[paste #1'); expect(editor.getText()).toContain('[paste #2'); - editor.setText('[paste #1 +15 lines] [paste #2 +15 lines]'); - simulateLargePaste(editor, 'anything'); expect(editor.getText()).toContain('[paste #1'); @@ -550,7 +548,9 @@ describe('CustomEditor paste marker expansion', () => { simulateLargePaste(editor, 'anything'); expect(editor.getText()).toContain(longText); - editor.setText(markerText); + // Undo (Ctrl+-) restores both the marker text and its paste-registry entry. + editor.handleInput('\x1b[45;5u'); + expect(editor.getText()).toContain('[paste #1'); simulateLargePaste(editor, 'anything'); expect(editor.getText()).not.toContain('[paste #'); diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index e078e6dd2ab..89dec20b98a 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { setMarkdownRenderLatex } from '#/tui/utils/markdown-options'; import { captureProcessWrite } from '../../../helpers/process'; @@ -17,7 +18,9 @@ vi.mock('cli-highlight', async () => { }); function strip(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(/\u001B\]133;[ABC]\u0007/g, ''); } describe('AssistantMessageComponent', () => { @@ -125,4 +128,31 @@ describe('AssistantMessageComponent', () => { finalTheme.highlightCode?.(code, 'typescript'); expect(highlightSpy).toHaveBeenCalled(); }); + + it('marks the rendered zone with OSC 133 markers, once across cache hits', () => { + const component = new AssistantMessageComponent(); + component.updateContent('hello'); + + const lines = component.render(80); + expect(lines[0]).toMatch(/^\u001B\]133;A\u0007/); + expect(lines[lines.length - 1]).toMatch(/^\u001B\]133;B\u0007\u001B\]133;C\u0007/); + + const cached = component.render(80); + expect(cached[0]).toBe(lines[0]); + }); + + it('renders LaTeX math by default and keeps raw source when disabled', () => { + const component = new AssistantMessageComponent(); + try { + setMarkdownRenderLatex(true); + component.updateContent('能量公式 $E = mc^2$'); + expect(strip(component.render(80).join('\n'))).toContain('E = mc²'); + + setMarkdownRenderLatex(false); + component.invalidate(); + expect(strip(component.render(80).join('\n'))).toContain('$E = mc^2$'); + } finally { + setMarkdownRenderLatex(true); + } + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index e6a10a05c07..7f8a1d1aec0 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -5,7 +5,9 @@ import { UserMessageComponent } from '#/tui/components/messages/user-message'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(/\u001B\]133;[ABC]\u0007/g, ''); } describe('UserMessageComponent', () => { @@ -104,4 +106,16 @@ describe('UserMessageComponent', () => { // The `$` sits at the leading column where the bullet used to be. expect(contentLine?.startsWith('$ ls')).toBe(true); }); + + it('marks the rendered zone with OSC 133 markers, once across cache hits', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent('hello', []); + + const lines = component.render(80); + expect(lines[0]).toMatch(/^\u001B\]133;A\u0007/); + expect(lines[lines.length - 1]).toMatch(/^\u001B\]133;B\u0007\u001B\]133;C\u0007/); + + const cached = component.render(80); + expect(cached[0]).toBe(lines[0]); + }); }); diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts index 76acd438ce5..314c7a18a86 100644 --- a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -28,23 +28,39 @@ function createMockSpinner(initialText = 'working') { describe('ActivityPaneComponent', () => { it('renders waiting loader after a spacer', () => { + const { spinner } = createMockSpinner('loading'); const component = new ActivityPaneComponent({ mode: 'waiting', - spinner: new Text('loading', 0, 0) as never, + spinner, }); expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'loading']); }); it('renders composing spinner after a spacer', () => { + const { spinner } = createMockSpinner('working'); const component = new ActivityPaneComponent({ mode: 'composing', - spinner: new Text('working', 0, 0) as never, + spinner, }); expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); + it('renders the detail line under the waiting spinner', () => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode: 'waiting', + spinner, + detail: '429 · rate limited', + }); + + const lines = component + .render(80) + .map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '').trimEnd()); + expect(lines).toEqual(['', 'working', ' 429 · rate limited']); + }); + it.each(['waiting', 'tool', 'composing'] as const)( 'renders %s spinner with tip after a spacer', (mode) => { diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 9ae144a2b4e..48df4730307 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -60,6 +60,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: 'code --wait', @@ -78,6 +79,16 @@ disable_paste_burst = true expect(config.disablePasteBurst).toBe(true); }); + it('defaults render_latex to true and parses false', () => { + expect(parseTuiConfig('').renderLatex).toBe(true); + + const config = parseTuiConfig(` +render_latex = false +`); + + expect(config.renderLatex).toBe(false); + }); + it('parses cache_expiry_hint', () => { const config = parseTuiConfig(` theme = "dark" @@ -95,6 +106,7 @@ command = " " expect(config).toEqual({ theme: 'auto', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: null, @@ -141,6 +153,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: 'vim', diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts new file mode 100644 index 00000000000..0c5588e46b4 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts @@ -0,0 +1,220 @@ +import type { Event } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { + SubAgentEventHandler, + type SubagentLifecycleEvent, +} from '#/tui/controllers/subagent-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeStreamingUIStub() { + return { + getToolComponent: vi.fn(() => undefined), + getActiveToolCall: vi.fn(() => undefined), + onToolCallStart: vi.fn(), + getTurnContext: vi.fn(() => ({ turnId: 1, step: 0 })), + removeToolComponentIfInactive: vi.fn(), + applyBackgroundTaskTerminalStatus: vi.fn(), + markSubagentBackgrounded: vi.fn(), + setTurnId: vi.fn(), + flushNow: vi.fn(), + setTodoList: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + }; +} + +function makeSubagentHandler() { + const backgroundTasks = new Map(); + const host = { + state: { + appState: { availableModels: {} }, + ui: { requestRender: vi.fn() }, + transcriptContainer: { addChild: vi.fn() }, + }, + streamingUI: makeStreamingUIStub(), + appendTranscriptEntry: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + updateActivityPane: vi.fn(), + }; + const handler = new SubAgentEventHandler(host as never, { + backgroundTasks, + backgroundTaskTranscriptedTerminal: new Set(), + syncBackgroundAgentBadge: vi.fn(), + }); + return { handler, backgroundTasks }; +} + +function spawnEvent(subagentId: string, runInBackground: boolean): SubagentLifecycleEvent { + return { + sessionId: 's1', + agentId: 'main', + type: 'subagent.spawned', + subagentId, + subagentName: 'explore', + parentToolCallId: `tc-${subagentId}`, + description: `task ${subagentId}`, + runInBackground, + } as unknown as SubagentLifecycleEvent; +} + +function completedEvent(subagentId: string): SubagentLifecycleEvent { + return { + sessionId: 's1', + agentId: 'main', + type: 'subagent.completed', + subagentId, + parentToolCallId: `tc-${subagentId}`, + resultSummary: 'done', + } as unknown as SubagentLifecycleEvent; +} + +describe('SubAgentEventHandler — activity record pruning', () => { + it('drops the record of a foreground-only subagent at terminal state', () => { + const { handler } = makeSubagentHandler(); + handler.handleLifecycleEvent(spawnEvent('a1', false)); + handler.activityStore.applyEvent({ + sessionId: 's1', + agentId: 'a1', + type: 'turn.step.started', + turnId: 1, + step: 0, + } as Event); + expect(handler.activityStore.get('a1')).toBeDefined(); + + handler.handleLifecycleEvent(completedEvent('a1')); + + expect(handler.activityStore.get('a1')).toBeUndefined(); + }); + + it('keeps the record of a spawn-time background agent even before the task syncs', () => { + const { handler } = makeSubagentHandler(); + handler.handleLifecycleEvent(spawnEvent('a2', true)); + handler.activityStore.applyEvent({ + sessionId: 's1', + agentId: 'a2', + type: 'turn.step.started', + turnId: 1, + step: 0, + } as Event); + + // No background.task.started has populated the task map yet. + handler.handleLifecycleEvent(completedEvent('a2')); + + const record = handler.activityStore.get('a2'); + expect(record?.status).toBe('completed'); + expect(record?.resultSummary).toBe('done'); + }); +}); + +function makeSessionEventHost() { + const host = { + state: { + appState: { + sessionId: 's1', + workDir: '/tmp/wd', + streamingPhase: 'idle', + availableModels: {}, + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + tasksBrowser: undefined, + footer: { setBackgroundCounts: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: makeStreamingUIStub(), + requireSession: vi.fn(), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: { repaint: vi.fn(), refreshOutputViewer: vi.fn() }, + }; + return host as never; +} + +describe('SessionEventHandler — background.task.terminated', () => { + function terminatedEvent(agentId: string, status: string): Event { + return { + sessionId: 's1', + agentId: 'main', + type: 'background.task.terminated', + info: { + taskId: `task-${agentId}`, + kind: 'agent', + agentId, + description: 'bg task', + status, + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event; + } + + it('marks a still-running record failed when an agent is stopped without subagent.failed', () => { + const handler = new SessionEventHandler(makeSessionEventHost()); + handler.subAgentEventHandler.activityStore.ensureRecord({ + agentId: 'agent-9', + agentName: 'explore', + parentToolCallId: 'tc-9', + }); + + handler.handleEvent(terminatedEvent('agent-9', 'killed'), vi.fn()); + + expect(handler.subAgentEventHandler.activityStore.get('agent-9')?.status).toBe('failed'); + }); + + it('does not overwrite a record that already reached terminal state with a summary', () => { + const handler = new SessionEventHandler(makeSessionEventHost()); + const store = handler.subAgentEventHandler.activityStore; + store.ensureRecord({ agentId: 'agent-8', agentName: 'explore', parentToolCallId: 'tc-8' }); + store.markCompleted('agent-8', 'final summary'); + + handler.handleEvent(terminatedEvent('agent-8', 'completed'), vi.fn()); + + const record = store.get('agent-8'); + expect(record?.status).toBe('completed'); + expect(record?.resultSummary).toBe('final summary'); + }); + + it('drops foreground-only records when the main turn ends (aborted subagents emit no lifecycle event)', () => { + const handler = new SessionEventHandler(makeSessionEventHost()); + const store = handler.subAgentEventHandler.activityStore; + store.ensureRecord({ agentId: 'agent-7', agentName: 'explore', parentToolCallId: 'tc-7' }); + + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'turn.ended', + turnId: 1, + reason: 'cancelled', + } as Event, + vi.fn(), + ); + + expect(store.get('agent-7')).toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts new file mode 100644 index 00000000000..a60aa55c636 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost() { + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + isCompacting: false, + model: 'kimi-model', + permissionMode: 'auto', + stepRetry: null, + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + setStep: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + finalizeLiveTextBuffers: vi.fn(), + completeToolResult: vi.fn(), + }, + requireSession: vi.fn(), + setAppState: vi.fn((patch: Record) => + Object.assign(host.state.appState, patch), + ), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any }; +} + +const retryingEvent = { + type: 'turn.step.retrying', + sessionId: 's1', + agentId: 'main', + turnId: 1, + step: 1, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, +} as const; + +describe('SessionEventHandler step retry state', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('stores the retry snapshot when a step starts retrying', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).toEqual({ + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, + phase: 'backoff', + }); + }); + + it('drives the pane back to waiting so mid-stream retries render', () => { + const { host } = makeHost(); + host.state.appState.streamingPhase = 'composing'; + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.patchLivePane).toHaveBeenCalledWith({ mode: 'waiting' }); + expect(host.state.appState.streamingPhase).toBe('waiting'); + }); + + it.each([ + [{ type: 'turn.step.completed', turnId: 1, step: 1 }, 'turn.step.completed'], + [ + { type: 'turn.step.interrupted', turnId: 1, step: 1, reason: 'error' }, + 'turn.step.interrupted', + ], + [{ type: 'turn.ended', turnId: 1, reason: 'completed' }, 'turn.ended'], + [ + { type: 'tool.result', turnId: 1, toolCallId: 'tc1', output: 'ok', isError: false }, + 'tool.result', + ], + ])('clears the retry snapshot on %s', (event, _label) => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).not.toBeNull(); + handler.handleEvent( + { sessionId: 's1', agentId: 'main', ...event } as any, + vi.fn(), + ); + expect(host.state.appState.stepRetry).toBeNull(); + }); + + it('flips to attempt phase once the backoff delay elapses', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).toMatchObject({ phase: 'backoff' }); + vi.advanceTimersByTime(4000); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'attempt' }); + }); + + it('cancels the phase flip when the retry is cleared during the backoff', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.handleEvent( + { + type: 'turn.step.interrupted', + sessionId: 's1', + agentId: 'main', + turnId: 1, + step: 1, + reason: 'error', + } as any, + vi.fn(), + ); + vi.advanceTimersByTime(10_000); + expect(host.state.appState.stepRetry).toBeNull(); + }); + + it('keeps the retry snapshot on turn.step.started (v2 re-emits it per attempt)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.handleEvent( + { type: 'turn.step.started', sessionId: 's1', agentId: 'main', turnId: 1, step: 1 } as any, + vi.fn(), + ); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'backoff' }); + }); + + it('cancels the pending phase flip via clearStepRetryAttemptTimer (TUI shutdown path)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.clearStepRetryAttemptTimer(); + vi.advanceTimersByTime(10_000); + expect(host.state.appState.stepRetry).toMatchObject({ phase: 'backoff' }); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/subagent-activity-store.test.ts b/apps/kimi-code/test/tui/controllers/subagent-activity-store.test.ts new file mode 100644 index 00000000000..d7c73f2a78a --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/subagent-activity-store.test.ts @@ -0,0 +1,294 @@ +import type { Event } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { + MAX_SUBAGENT_ACTIVITY_STEPS, + SUBAGENT_ARG_STRING_MAX_CHARS, + SUBAGENT_STEP_TEXT_TAIL_CHARS, + SUBAGENT_TOOL_OUTPUT_MAX_CHARS, +} from '#/tui/constant/rendering'; +import { STREAMING_ARGS_PREVIEW_MAX_CHARS } from '#/tui/constant/streaming'; +import { + SubagentActivityStore, + type SubagentActivitySpawn, +} from '#/tui/controllers/subagent-activity-store'; + +function ev(partial: Record): Event { + return { sessionId: 's1', agentId: 'agent-1', ...partial } as unknown as Event; +} + +function spawn(overrides: Partial = {}): SubagentActivitySpawn { + return { + agentId: 'agent-1', + agentName: 'explore', + description: 'find things', + parentToolCallId: 'tc-1', + model: 'K3', + effort: 'high', + ...overrides, + }; +} + +describe('SubagentActivityStore', () => { + it('folds a full step lifecycle (text + tool call + result)', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.applyEvent(ev({ type: 'assistant.delta', turnId: 1, delta: 'Hello ' })); + store.applyEvent(ev({ type: 'assistant.delta', turnId: 1, delta: 'world' })); + store.applyEvent( + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 't1', name: 'Grep', args: { pattern: 'foo' } }), + ); + store.applyEvent( + ev({ type: 'tool.progress', turnId: 1, toolCallId: 't1', update: { kind: 'stdout', text: 'line1\nline2\n' } }), + ); + store.applyEvent( + ev({ type: 'tool.result', turnId: 1, toolCallId: 't1', output: 'a\nb\nc', isError: false }), + ); + + const record = store.get('agent-1'); + expect(record?.agentName).toBe('explore'); + expect(record?.steps).toHaveLength(1); + expect(record?.totalSteps).toBe(1); + expect(record?.steps[0]?.textTail).toBe('Hello world'); + const call = record?.steps[0]?.toolCalls[0]; + expect(call?.name).toBe('Grep'); + expect(call?.args).toEqual({ pattern: 'foo' }); + expect(call?.status).toBe('done'); + expect(call?.result?.output).toBe('a\nb\nc'); + expect(call?.result?.is_error).toBe(false); + expect(call?.liveOutputTail).toBeUndefined(); + expect(call?.durationMs).toBeGreaterThanOrEqual(0); + expect(record?.version).toBeGreaterThan(0); + }); + + it('creates a call from streaming deltas and replaces args on start', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't1', name: 'Bash', argumentsPart: '{"command":"ls' }), + ); + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't1', argumentsPart: ' -la"}' }), + ); + + let record = store.get('agent-1'); + // No step event yet — a synthetic step holds the in-flight call. + expect(record?.steps).toHaveLength(1); + expect(record?.steps[0]?.toolCalls[0]?.args).toEqual({ command: 'ls -la' }); + + store.applyEvent( + ev({ + type: 'tool.call.started', + turnId: 1, + toolCallId: 't1', + name: 'Bash', + args: { command: 'ls -la', timeout: 5 }, + }), + ); + record = store.get('agent-1'); + expect(record?.steps[0]?.toolCalls[0]?.args).toEqual({ command: 'ls -la', timeout: 5 }); + }); + + it('evicts whole steps beyond the cap while totalSteps keeps counting', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + for (let i = 0; i < MAX_SUBAGENT_ACTIVITY_STEPS + 2; i++) { + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: i })); + } + const record = store.get('agent-1'); + expect(record?.steps).toHaveLength(MAX_SUBAGENT_ACTIVITY_STEPS); + expect(record?.totalSteps).toBe(MAX_SUBAGENT_ACTIVITY_STEPS + 2); + expect(record?.steps[0]?.step).toBe(2); + }); + + it('keeps only the tail of long assistant text', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.applyEvent( + ev({ + type: 'assistant.delta', + turnId: 1, + delta: 'x'.repeat(SUBAGENT_STEP_TEXT_TAIL_CHARS) + 'y'.repeat(100), + }), + ); + const step = store.get('agent-1')?.steps[0]; + expect(step?.textTail).toHaveLength(SUBAGENT_STEP_TEXT_TAIL_CHARS); + expect(step?.textTail.endsWith('y'.repeat(100))).toBe(true); + }); + + it('caps tool output and appends a truncation sentinel', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.applyEvent( + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 't1', name: 'Bash', args: {} }), + ); + store.applyEvent( + ev({ + type: 'tool.result', + turnId: 1, + toolCallId: 't1', + output: 'y'.repeat(SUBAGENT_TOOL_OUTPUT_MAX_CHARS + 100), + }), + ); + const call = store.get('agent-1')?.steps[0]?.toolCalls[0]; + expect(call?.result?.output.startsWith('yyy')).toBe(true); + expect(call?.result?.output).toContain('[output truncated'); + expect(call?.result?.output.length).toBeLessThan(SUBAGENT_TOOL_OUTPUT_MAX_CHARS + 120); + }); + + it('marks the current step on retry without opening a new one', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.applyEvent( + ev({ + type: 'turn.step.retrying', + turnId: 1, + step: 0, + nextAttempt: 2, + maxAttempts: 5, + errorName: 'RateLimitError', + }), + ); + let record = store.get('agent-1'); + expect(record?.steps).toHaveLength(1); + expect(record?.steps[0]?.retrying).toContain('2/5'); + + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + record = store.get('agent-1'); + expect(record?.steps[1]?.retrying).toBeUndefined(); + }); + + it('implicitly creates a record for events from an unseen agent', () => { + const store = new SubagentActivityStore(); + store.applyEvent(ev({ type: 'assistant.delta', turnId: 1, delta: 'hi' })); + const record = store.get('agent-1'); + expect(record?.agentName).toBe('agent-1'); + expect(record?.steps[0]?.textTail).toBe('hi'); + }); + + it('drops results for unknown agents instead of creating records', () => { + const store = new SubagentActivityStore(); + store.applyEvent(ev({ type: 'tool.result', turnId: 1, toolCallId: 't1', output: 'x' })); + expect(store.get('agent-1')).toBeUndefined(); + }); + + it('caps the raw streaming-args buffer at the preview window', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent( + ev({ + type: 'tool.call.delta', + turnId: 1, + toolCallId: 't1', + name: 'Write', + argumentsPart: 'x'.repeat(STREAMING_ARGS_PREVIEW_MAX_CHARS + 1000), + }), + ); + const buffers = ( + store as unknown as { streamingArgs: Map } + ).streamingArgs; + expect(buffers.get('agent-1:t1')?.length).toBeLessThanOrEqual(STREAMING_ARGS_PREVIEW_MAX_CHARS); + }); + + it('tracks terminal state and resets it on respawn', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.markCompleted('agent-1', 'done summary'); + let record = store.get('agent-1'); + expect(record?.status).toBe('completed'); + expect(record?.resultSummary).toBe('done summary'); + + store.ensureRecord(spawn()); + record = store.get('agent-1'); + expect(record?.status).toBe('running'); + expect(record?.resultSummary).toBeUndefined(); + + store.markFailed('agent-1', 'boom'); + record = store.get('agent-1'); + expect(record?.status).toBe('failed'); + expect(record?.error).toBe('boom'); + }); + + it('clear() releases all records', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.clear(); + expect(store.get('agent-1')).toBeUndefined(); + }); + + it('drop() removes one record along with its streaming buffers', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.ensureRecord(spawn({ agentId: 'agent-2', agentName: 'general' })); + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't1', name: 'Write', argumentsPart: '{"path":"a"}' }), + ); + + store.drop('agent-1'); + + expect(store.get('agent-1')).toBeUndefined(); + expect(store.get('agent-2')).toBeDefined(); + const buffers = ( + store as unknown as { streamingArgs: Map } + ).streamingArgs; + expect([...buffers.keys()].every((key) => !key.startsWith('agent-1:'))).toBe(true); + }); + + it('caps long string argument values retained in a record', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent( + ev({ + type: 'tool.call.started', + turnId: 1, + toolCallId: 't1', + name: 'Write', + args: { path: 'a.ts', content: 'c'.repeat(SUBAGENT_ARG_STRING_MAX_CHARS + 500) }, + }), + ); + const call = store.get('agent-1')?.steps[0]?.toolCalls[0]; + expect(typeof call?.args['content']).toBe('string'); + expect((call?.args['content'] as string).length).toBeLessThanOrEqual( + SUBAGENT_ARG_STRING_MAX_CHARS + 1, + ); + expect(call?.args['path']).toBe('a.ts'); + }); + + it('drops delta-only arg buffers when their step is evicted', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + // A call truncated before started/result only ever produced deltas. + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't-trunc', name: 'Write', argumentsPart: '{"path":"a"' }), + ); + const buffers = ( + store as unknown as { streamingArgs: Map } + ).streamingArgs; + expect(buffers.has('agent-1:t-trunc')).toBe(true); + + for (let i = 0; i < MAX_SUBAGENT_ACTIVITY_STEPS; i++) { + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: i })); + } + expect(buffers.has('agent-1:t-trunc')).toBe(false); + }); + + it('drops leftover arg buffers when the record turns terminal', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't-trunc', name: 'Write', argumentsPart: '{"path":"a"' }), + ); + const buffers = ( + store as unknown as { streamingArgs: Map } + ).streamingArgs; + expect(buffers.has('agent-1:t-trunc')).toBe(true); + + store.markCompleted('agent-1', 'done'); + expect(buffers.has('agent-1:t-trunc')).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 33518f46a1d..efa91617c1a 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; + +import { TuiAltScreen, TuiMainScreen } from '@moonshot-ai/pi-tui'; import { createTUIState, type KimiTUIOptions } from '#/tui/kimi-tui'; import type { AppState } from '#/tui/types'; @@ -23,6 +25,7 @@ function fakeInitialAppState(): AppState { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, theme: 'dark', version: '0.0.0-test', editorCommand: null, @@ -85,4 +88,61 @@ describe('createTUIState', () => { expect(state.sessionsScope).toBe('cwd'); expect(state.activitySpinner).toBeNull(); }); + + it('uses the main-screen renderer by default', () => { + const state = createTUIState({ + initialAppState: fakeInitialAppState(), + startup: { + continueLast: false, + yolo: false, + auto: false, + plan: false, + }, + }); + + expect(state.ui).toBeInstanceOf(TuiMainScreen); + expect(state.ui.mode).toBe('regular'); + expect(state.dockContainer).toBeUndefined(); + }); + + it('builds an alternate-screen renderer with a docked layout in fullscreen mode', () => { + vi.stubEnv('KIMI_CODE_TUI_FULL_SCREEN', '1'); + const state = createTUIState({ + initialAppState: fakeInitialAppState(), + startup: { + continueLast: false, + yolo: false, + auto: false, + plan: false, + }, + }); + vi.unstubAllEnvs(); + + expect(state.ui).toBeInstanceOf(TuiAltScreen); + expect(state.ui.mode).toBe('fullscreen'); + + // The chrome docks below the transcript ScrollView, in z-order. + const dock = state.dockContainer; + expect(dock).toBeDefined(); + expect(dock?.children).toEqual([ + state.activityContainer, + state.todoPanelContainer, + state.queueContainer, + state.btwPanelContainer, + state.editorContainer, + ]); + + // The layout root is mounted and the root children list stays empty. + expect((state.ui as TuiAltScreen).getLayoutRoot()).toBeDefined(); + expect(state.ui.children).toHaveLength(0); + + // Mouse capture replaces native terminal link activation / right-click + // paste, so both must be routed through renderer callbacks. + const internals = state.ui as unknown as { + openUrl?: (url: string) => void; + onRightClickPaste?: () => void; + }; + expect(typeof internals.openUrl).toBe('function'); + expect(typeof internals.onRightClickPaste).toBe('function'); + }); }); diff --git a/apps/kimi-code/test/tui/fullscreen-layout.test.ts b/apps/kimi-code/test/tui/fullscreen-layout.test.ts new file mode 100644 index 00000000000..74caf3f7a56 --- /dev/null +++ b/apps/kimi-code/test/tui/fullscreen-layout.test.ts @@ -0,0 +1,171 @@ +/** + * Fullscreen layout contract tests: the docked chrome must keep the editor's + * full height (top border / input / bottom border) even when the transcript + * far exceeds the screen. Regression: the dock used to participate in VStack + * shrink distribution with no minSize, so a tall transcript crushed it and + * the editor's bottom border row was clipped off screen. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { Spacer, type Terminal, TuiAltScreen } from '@moonshot-ai/pi-tui'; +import { VirtualTerminal } from '../../../../packages/pi-tui/test/virtual-terminal'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StatusMessageComponent } from '#/tui/components/messages/status-message'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; +import { CHROME_GUTTER } from '#/tui/constant/rendering'; +import { createTUIState, type KimiTUIOptions } from '#/tui/kimi-tui'; +import type { AppState } from '#/tui/types'; + +const WIDTH = 120; +const HEIGHT = 30; + +function fakeInitialAppState(): AppState { + return { + model: 'test-model', + workDir: '/tmp/kimi-test', + additionalDirs: [], + sessionId: 'sess-1', + permissionMode: 'manual', + planMode: false, + inputMode: 'prompt', + swarmMode: false, + supermoonMode: false, + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + stepRetry: null, + theme: 'dark', + version: '0.0.0-test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + sessionTitle: null, + mcpServersSummary: null, + }; +} + +function stripAnsi(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, ''); +} + +const LONG_MARKDOWN = Array.from( + { length: 40 }, + (_, i) => `### Section ${i + 1}\n\nSome **bold** and \`code\` content in paragraph ${i + 1}.\n`, +).join('\n'); + +async function mountFullscreen(): Promise<{ + state: ReturnType; + vt: VirtualTerminal; +}> { + const opts: KimiTUIOptions = { + initialAppState: fakeInitialAppState(), + startup: { continueLast: false, yolo: false, auto: false, plan: false }, + }; + vi.stubEnv('KIMI_CODE_TUI_FULL_SCREEN', '1'); + const state = createTUIState(opts); + vi.unstubAllEnvs(); + const vt = new VirtualTerminal(WIDTH, HEIGHT); + (state.ui as { terminal: Terminal }).terminal = vt; + + // Footer is mounted into the dock after init (mirrors mountFooter()). + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(state.footer); + state.dockContainer?.addChild(footerWrap, { shrink: 1, minSize: 1 }); + state.editorContainer.addChild(state.editor); + state.ui.setFocus(state.editor); + state.ui.start(); + await vt.waitForRender(); + return { state, vt }; +} + +describe('fullscreen layout', () => { + it('keeps the editor bottom border visible after a streaming grow/shrink cycle', async () => { + const { state, vt } = await mountFullscreen(); + expect(state.ui).toBeInstanceOf(TuiAltScreen); + + const screenRows = (): string[] => { + const rows: string[] = []; + for (let i = 0; i < HEIGHT; i++) rows.push(stripAnsi(vt.getViewport()[i] ?? '').trimEnd()); + return rows; + }; + + // User message, then a streaming assistant message with the activity pane up. + state.transcriptContainer.addChild(new UserMessageComponent('分析下这个项目')); + const spinner = new MoonLoader(state.ui); + state.activityContainer.addChild( + new ActivityPaneComponent({ mode: 'tool', spinner, tip: 'streaming' }), + ); + const assistant = new AssistantMessageComponent(); + state.transcriptContainer.addChild(assistant); + assistant.updateContent(LONG_MARKDOWN, { transient: true }); + state.ui.requestRender(true); + await vt.waitForRender(); + + // Streaming ends: final highlight, spinner -> one-row placeholder, debug line. + assistant.updateContent(LONG_MARKDOWN, { transient: false }); + state.activityContainer.clear(); + state.activityContainer.addChild(new Spacer(1)); + state.transcriptContainer.addChild( + new StatusMessageComponent('[Debug] TTFT: 4.3s | TPS: 203 tok/s'), + ); + state.ui.requestRender(true); + await vt.waitForRender(); + + const rows = screenRows(); + const promptRow = rows.findIndex((line) => /│\s*>/.test(line)); + expect(promptRow).toBeGreaterThan(0); + expect(rows[promptRow + 1]).toContain('╰'); + + state.ui.stop(); + }); + + it('jumps between prompts with Ctrl-Shift-Up/Down (OSC 133 zones survive the chain)', async () => { + const { state, vt } = await mountFullscreen(); + + state.transcriptContainer.addChild(new UserMessageComponent('第一轮提问')); + const first = new AssistantMessageComponent(); + state.transcriptContainer.addChild(first); + first.updateContent(`回答一\n\n${LONG_MARKDOWN}`); + state.transcriptContainer.addChild(new UserMessageComponent('第二轮提问')); + const second = new AssistantMessageComponent(); + state.transcriptContainer.addChild(second); + second.updateContent(`回答二\n\n${LONG_MARKDOWN}`); + state.ui.requestRender(true); + await vt.waitForRender(); + + const alt = state.ui as TuiAltScreen; + expect(alt.isFollowingOutput).toBe(true); + + const topRows = (): string[] => + Array.from({ length: 6 }, (_, i) => stripAnsi(vt.getViewport()[i] ?? '').trimEnd()); + + // Zones anchor every user/assistant message, so the nearest previous zone + // below the fold is the current turn's assistant message, then the user + // message that started the turn. + vt.sendInput('\x1b[1;6A'); // ctrl+shift+up = previous prompt + await vt.waitForRender(); + expect(topRows()[1]).toContain('回答二'); + + vt.sendInput('\x1b[1;6A'); + await vt.waitForRender(); + expect(topRows()[1]).toContain('第二轮提问'); + + vt.sendInput('\x1b[1;6B'); // ctrl+shift+down = next prompt + await vt.waitForRender(); + expect(topRows()[1]).toContain('回答二'); + + state.ui.stop(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index df67e045498..cea085ee9b3 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -269,7 +269,7 @@ function makeSession(overrides: Record = {}) { function makeHarness(session = makeSession(), overrides: Record = {}) { const interactiveAgentScope = new AsyncLocalStorage(); - return { + const harness = { getConfig: vi.fn(async () => ({ models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, @@ -316,6 +316,23 @@ function makeHarness(session = makeSession(), overrides: Record }, ...overrides, }; + // The TUI lists sessions through keyset pages; derive the page mock from + // the (possibly overridden) full-list mock unless a test overrides paging. + if (!('listSessionsPage' in harness)) { + const listSessions = harness.listSessions as (input?: { + workDir?: string; + sessionId?: string; + }) => Promise; + Object.assign(harness, { + listSessionsPage: vi.fn( + async (input: { workDir?: string; sessionId?: string } = {}) => ({ + items: await listSessions({ workDir: input.workDir, sessionId: input.sessionId }), + nextCursor: undefined, + }), + ), + }); + } + return harness; } async function makeDriver( diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index fe816442b64..1d3132a352e 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -193,7 +193,7 @@ function loginRequiredError(): Error & { readonly code: string } { } function makeHarness(session = makeSession(), overrides: Record = {}) { - return { + const harness = { getConfig: vi.fn(async () => ({ models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, @@ -215,6 +215,23 @@ function makeHarness(session = makeSession(), overrides: Record }, ...overrides, }; + // The TUI lists sessions through keyset pages; derive the page mock from + // the (possibly overridden) full-list mock unless a test overrides paging. + if (!('listSessionsPage' in harness)) { + const listSessions = harness.listSessions as (input?: { + workDir?: string; + sessionId?: string; + }) => Promise; + Object.assign(harness, { + listSessionsPage: vi.fn( + async (input: { workDir?: string; sessionId?: string } = {}) => ({ + items: await listSessions({ workDir: input.workDir, sessionId: input.sessionId }), + nextCursor: undefined, + }), + ), + }); + } + return harness; } function makeDriver(harness: ReturnType, input: KimiTUIStartupInput) { @@ -309,6 +326,24 @@ describe('KimiTUI startup', () => { }); }); + it('mounts the docked fullscreen layout when KIMI_CODE_TUI_FULL_SCREEN=1', async () => { + const harness = makeHarness(makeSession()); + vi.stubEnv('KIMI_CODE_TUI_FULL_SCREEN', '1'); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + vi.unstubAllEnvs(); + + // buildLayout() runs in the constructor: fullscreen keeps the root + // children list empty and mounts the layout root instead. + expect(driver.state.ui.mode).toBe('fullscreen'); + expect(driver.state.ui.children).toHaveLength(0); + + await expect(driver.init()).resolves.toBe(false); + (driver as unknown as { mountFooter(): void }).mountFooter(); + + // Dock = 5 chrome containers + footer wrap, below the transcript viewport. + expect(driver.state.dockContainer?.children).toHaveLength(6); + }); + it('shows a session-less notice on v2 startup', async () => { const harness = makeHarness(makeSession()); const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); @@ -1057,6 +1092,127 @@ describe('KimiTUI startup', () => { expect(mountSessionPicker).toHaveBeenCalledTimes(1); }); + function makePagedListSessionsPage() { + const firstPage = Array.from({ length: 50 }, (_, index) => ({ + id: `ses-page1-${String(index).padStart(2, '0')}`, + workDir: '/tmp/proj-a', + updatedAt: Date.now() - index * 1000, + })); + return vi.fn(async (input: { workDir?: string; before?: string } = {}) => + input.before === undefined + ? { items: firstPage, nextCursor: 'ses-page1-49' } + : { + items: [{ id: 'ses-page2-0', workDir: '/tmp/proj-a', updatedAt: 0 }], + nextCursor: undefined, + }, + ); + } + + it('fetches the next session page when the picker scrolls to the fetched end', async () => { + const listSessionsPage = makePagedListSessionsPage(); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + expect(listSessionsPage).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', limit: 50 }); + expect(driver.state.sessions).toHaveLength(50); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + for (let i = 0; i < 49; i++) { + picker.handleInput('\u001B[B'); + } + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(51); + }); + + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + expect(driver.state.sessions.map((session) => session.id)).toContain('ses-page2-0'); + }); + + it('drains the remaining session pages in the background once a query is typed', async () => { + const listSessionsPage = makePagedListSessionsPage(); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + expect(driver.state.sessions).toHaveLength(50); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('x'); + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(51); + }); + + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + }); + + it('continues the search drain after an in-flight scroll fetch settles', async () => { + const firstPage = Array.from({ length: 50 }, (_, index) => ({ + id: `ses-page1-${String(index).padStart(2, '0')}`, + workDir: '/tmp/proj-a', + updatedAt: Date.now() - index * 1000, + })); + let resolveScrollPage!: (page: { items: unknown[]; nextCursor?: string }) => void; + const listSessionsPage = vi.fn((input: { workDir?: string; before?: string } = {}) => { + if (input.before === undefined) { + return Promise.resolve({ items: firstPage, nextCursor: 'ses-page1-49' }); + } + if (input.before === 'ses-page1-49') { + // The scroll-triggered page fetch stays pending until the test resolves it. + return new Promise<{ items: unknown[]; nextCursor?: string }>((resolve) => { + resolveScrollPage = resolve; + }); + } + return Promise.resolve({ + items: [{ id: 'ses-page3-0', workDir: '/tmp/proj-a', updatedAt: 0 }], + nextCursor: undefined, + }); + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + // Reach the fetched end: the scroll-triggered fetch for page 2 starts. + for (let i = 0; i < 49; i++) { + picker.handleInput('\u001B[B'); + } + await vi.waitFor(() => { + expect(listSessionsPage).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + }); + + // Typing a query while that fetch is in flight must join it, not stop the + // drain: the remaining pages arrive after the in-flight one settles. + picker.handleInput('x'); + resolveScrollPage({ + items: [{ id: 'ses-page2-0', workDir: '/tmp/proj-a', updatedAt: 1 }], + nextCursor: 'ses-page2-0', + }); + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(52); + }); + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page2-0', + }); + }); + it('clears the sessions picker search query when toggling scope with Ctrl+A', async () => { const currentWorkDirSession = { id: 'ses-cwd', @@ -1941,6 +2097,80 @@ describe('KimiTUI startup', () => { expect(driver.terminalFocusTrackingDispose).toBeUndefined(); }); + it('checks workspace trust before entering the migration screen', async () => { + // The migration branch used to skip the trust gate entirely: a workspace + // with legacy ~/.kimi data went straight to the migration screen, and + // later startup steps spawned child processes in an untrusted directory. + const getWorkspaceTrustInfo = vi.fn(async () => ({ + trusted: true, + gatedMcpServers: [], + })); + const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo }); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + engineV2: true, + }) as unknown as MigrateExitDriver; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + const migrationSpy = vi + .spyOn(driver, 'runMigrationScreen') + .mockResolvedValue({ decision: 'later' }); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + await driver.start(); + + expect(getWorkspaceTrustInfo).toHaveBeenCalledWith('/tmp/proj-a'); + expect(getWorkspaceTrustInfo.mock.invocationCallOrder[0]!).toBeLessThan( + migrationSpy.mock.invocationCallOrder[0]!, + ); + expect(onExit).toHaveBeenCalledWith(0); + }); + + it('prompts for workspace trust before migrating an untrusted workspace', async () => { + const getWorkspaceTrustInfo = vi.fn(async () => ({ + trusted: false, + gatedMcpServers: [], + })); + const trustWorkspace = vi.fn(async () => {}); + const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo, trustWorkspace }); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + engineV2: true, + }) as unknown as MigrateExitDriver & { + mountEditorReplacement(panel: { handleInput(data: string): void }): void; + }; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + const migrationSpy = vi + .spyOn(driver, 'runMigrationScreen') + .mockResolvedValue({ decision: 'later' }); + const mountSpy = vi.spyOn(driver, 'mountEditorReplacement'); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + const startPromise = driver.start(); + await vi.waitFor(() => { + expect(mountSpy).toHaveBeenCalled(); + }); + // Move from the safe default to the explicit trust choice, then confirm. + mountSpy.mock.calls[0]![0].handleInput('\u001B[A'); + mountSpy.mock.calls[0]![0].handleInput('\r'); + await startPromise; + + expect(trustWorkspace).toHaveBeenCalledWith('/tmp/proj-a'); + expect(getWorkspaceTrustInfo.mock.invocationCallOrder[0]!).toBeLessThan( + migrationSpy.mock.invocationCallOrder[0]!, + ); + expect(onExit).toHaveBeenCalledWith(0); + }); + it('keeps non-login startup session errors fatal', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index 331389a56aa..7d5a54c3bff 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -1,5 +1,5 @@ import type { Terminal } from '@moonshot-ai/pi-tui'; -import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import type { BackgroundTaskInfo, BackgroundTaskStatus, Event } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; import { @@ -7,6 +7,10 @@ import { type TasksBrowserProps, type TasksFilter, } from '@/tui/components/dialogs/tasks-browser'; +import { AgentActivityViewer } from '@/tui/components/dialogs/agent-activity-viewer'; +import { TaskOutputViewer } from '@/tui/components/dialogs/task-output-viewer'; +import { SubagentActivityStore } from '@/tui/controllers/subagent-activity-store'; +import { TasksBrowserController } from '@/tui/controllers/tasks-browser'; import { darkColors } from '@/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -539,3 +543,123 @@ describe('TasksBrowserApp — setProps', () => { } }); }); + +describe('TasksBrowserController — opening an agent task', () => { + function makeControllerHost(tasks: BackgroundTaskInfo[], store: SubagentActivityStore) { + const ui = { + children: [] as unknown[], + clear() { + this.children = []; + }, + addChild(child: unknown) { + this.children.push(child); + }, + setFocus: () => {}, + requestRender: () => {}, + }; + const state = { + tasksBrowser: undefined as unknown, + terminal: fakeTerminal(30), + ui, + editor: {}, + }; + const host = { + state, + backgroundTasks: new Map(tasks.map((t) => [t.taskId, t])), + sessionEventHandler: { subAgentEventHandler: { activityStore: store } }, + session: { + listBackgroundTasks: async () => tasks, + getBackgroundTaskOutput: async () => 'captured output', + }, + showError: vi.fn(), + setTasksBrowser(value: unknown) { + state.tasksBrowser = value; + }, + }; + return { host, state }; + } + + function agentTaskInfo(store: SubagentActivityStore | null): BackgroundTaskInfo { + const info = task({ + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent-1', + status: 'running', + } as Partial); + if (store !== null) { + store.ensureRecord({ agentId: 'agent-1', agentName: 'explore', parentToolCallId: 'tc-1' }); + } + return info; + } + + async function openSelectedViewer(controller: TasksBrowserController, taskId: string) { + await ( + controller as unknown as { handleOpenOutput(taskId: string): Promise } + ).handleOpenOutput(taskId); + } + + it('opens the activity viewer when a record exists for the agent', async () => { + const store = new SubagentActivityStore(); + const { host, state } = makeControllerHost([agentTaskInfo(store)], store); + const controller = new TasksBrowserController(host as never); + await controller.show(); + + await openSelectedViewer(controller, 'agent-task-1'); + + const viewer = (state.tasksBrowser as { viewer: { component: unknown } }).viewer; + expect(viewer.component).toBeInstanceOf(AgentActivityViewer); + controller.close(); + }); + + it('falls back to the output viewer when no record exists', async () => { + const store = new SubagentActivityStore(); + const { host, state } = makeControllerHost([agentTaskInfo(null)], store); + const controller = new TasksBrowserController(host as never); + await controller.show(); + + await openSelectedViewer(controller, 'agent-task-1'); + + const viewer = (state.tasksBrowser as { viewer: { component: unknown } }).viewer; + expect(viewer.component).toBeInstanceOf(TaskOutputViewer); + controller.close(); + }); + + it('feeds the preview pane from the activity store for agent tasks', async () => { + const store = new SubagentActivityStore(); + store.ensureRecord({ agentId: 'agent-1', agentName: 'explore', parentToolCallId: 'tc-1' }); + store.applyEvent({ + sessionId: 's1', + agentId: 'agent-1', + type: 'turn.step.started', + turnId: 1, + step: 0, + } as Event); + store.applyEvent({ + sessionId: 's1', + agentId: 'agent-1', + type: 'tool.call.started', + turnId: 1, + toolCallId: 't1', + name: 'Grep', + args: { pattern: 'foo' }, + } as Event); + store.applyEvent({ + sessionId: 's1', + agentId: 'agent-1', + type: 'tool.result', + turnId: 1, + toolCallId: 't1', + output: 'src/a.ts:1:foo\nsrc/b.ts:2:foo', + isError: false, + } as Event); + + const { host, state } = makeControllerHost([agentTaskInfo(null)], store); + const controller = new TasksBrowserController(host as never); + await controller.show(); + + const browser = state.tasksBrowser as { tailOutput?: string }; + expect(browser.tailOutput).toContain('── step 0 ──'); + expect(browser.tailOutput).toContain('✓ Used Grep (foo) · 2 matches'); + controller.close(); + }); +}); diff --git a/apps/kimi-code/test/tui/tui-frame.bench.ts b/apps/kimi-code/test/tui/tui-frame.bench.ts index 2fc06ec0275..0ada071af35 100644 --- a/apps/kimi-code/test/tui/tui-frame.bench.ts +++ b/apps/kimi-code/test/tui/tui-frame.bench.ts @@ -14,7 +14,7 @@ */ import type { Component, Terminal } from '@moonshot-ai/pi-tui'; -import { TUI } from '@moonshot-ai/pi-tui'; +import { TuiMainScreen } from '@moonshot-ai/pi-tui'; import { bench, describe } from 'vitest'; const WIDTH = 120; @@ -72,7 +72,7 @@ class SpinnerComponent implements Component { describe('TUI steady-state frame', () => { const terminal = new StubTerminal(); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const spinner = new SpinnerComponent(); tui.addChild( new StaticTranscript( diff --git a/apps/kimi-code/test/tui/utils/screen-takeover.test.ts b/apps/kimi-code/test/tui/utils/screen-takeover.test.ts new file mode 100644 index 00000000000..c3132bc30dc --- /dev/null +++ b/apps/kimi-code/test/tui/utils/screen-takeover.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import type { Component, Terminal } from '@moonshot-ai/pi-tui'; +import { Text, TuiAltScreen, TuiMainScreen } from '@moonshot-ai/pi-tui'; + +import { beginScreenTakeover, endScreenTakeover } from '#/tui/utils/screen-takeover'; + +/** Minimal Terminal stub: takeover logic never starts the terminal. */ +function stubTerminal(): Terminal { + return { + start: () => {}, + stop: () => {}, + drainInput: async () => {}, + write: () => {}, + get columns() { + return 80; + }, + get rows() { + return 24; + }, + get kittyProtocolActive() { + return false; + }, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; +} + +function line(text: string): Component { + return new Text(text, 0, 0); +} + +describe('screen-takeover', () => { + it('swaps and restores root children in regular mode', () => { + const ui = new TuiMainScreen(stubTerminal()); + const transcript = line('transcript'); + const editor = line('editor'); + ui.addChild(transcript); + ui.addChild(editor); + + const viewer = line('viewer'); + const takeover = beginScreenTakeover(ui, viewer); + expect(ui.children).toEqual([viewer]); + + endScreenTakeover(ui, takeover); + expect(ui.children).toEqual([transcript, editor]); + }); + + it('swaps and restores the layout root in fullscreen mode', () => { + const ui = new TuiAltScreen(stubTerminal()); + const mainRoot = line('main-layout'); + ui.setLayoutRoot(mainRoot); + // The root children list is unused in fullscreen and stays empty. + expect(ui.children).toHaveLength(0); + + const viewer = line('viewer'); + const takeover = beginScreenTakeover(ui, viewer); + expect(ui.getLayoutRoot()).toBe(viewer); + + endScreenTakeover(ui, takeover); + expect(ui.getLayoutRoot()).toBe(mainRoot); + }); + + it('nests takeovers (viewer opened from a viewer)', () => { + const ui = new TuiAltScreen(stubTerminal()); + const mainRoot = line('main-layout'); + ui.setLayoutRoot(mainRoot); + + const browser = line('browser'); + const first = beginScreenTakeover(ui, browser); + const detail = line('detail'); + const second = beginScreenTakeover(ui, detail); + expect(ui.getLayoutRoot()).toBe(detail); + + endScreenTakeover(ui, second); + expect(ui.getLayoutRoot()).toBe(browser); + endScreenTakeover(ui, first); + expect(ui.getLayoutRoot()).toBe(mainRoot); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/searchable-list.test.ts b/apps/kimi-code/test/tui/utils/searchable-list.test.ts index 170b8993a46..698d1a60496 100644 --- a/apps/kimi-code/test/tui/utils/searchable-list.test.ts +++ b/apps/kimi-code/test/tui/utils/searchable-list.test.ts @@ -97,4 +97,24 @@ describe('SearchableList', () => { expect(search.handleKey(BACKSPACE)).toBe(true); expect(search.view().query).toBe(''); }); + + it('setItems replaces the items, keeps the query, and clamps the cursor', () => { + const list = make({ searchable: true }); + for (const ch of 'zz') list.handleKey(ch); + list.setItems([...ITEMS, 'item10']); + // The active query survives an items swap and still filters. + expect(list.view().query).toBe('zz'); + expect(list.view().items).toHaveLength(0); + + expect(list.clearQuery()).toBe(true); + for (let i = 0; i < 20; i++) list.moveDown(); + expect(list.view().selectedIndex).toBe(10); + + // Shrinking the set clamps the cursor into the new range. + list.setItems(['item00']); + const v = list.view(); + expect(v.items).toEqual(['item00']); + expect(v.selectedIndex).toBe(0); + expect(list.selected()).toBe('item00'); + }); }); diff --git a/apps/kimi-code/test/tui/utils/step-retry.test.ts b/apps/kimi-code/test/tui/utils/step-retry.test.ts new file mode 100644 index 00000000000..9111471c888 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/step-retry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { RETRY_DETAIL_MAX_CHARS } from '#/tui/constant/rendering'; +import { formatStepRetryDetail, formatStepRetryLabel } from '#/tui/utils/step-retry'; +import type { StepRetryState } from '#/tui/types'; + +function retry(partial: Partial = {}): StepRetryState { + return { + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, + phase: 'backoff', + ...partial, + }; +} + +describe('formatStepRetryLabel', () => { + it('shows attempts, raw error name, and backoff delay', () => { + expect(formatStepRetryLabel(retry())).toBe('Retrying (2/10) · APIStatusError · in 4s'); + }); + + it('drops the stale countdown once the attempt is running', () => { + expect(formatStepRetryLabel(retry({ phase: 'attempt' }))).toBe( + 'Retrying (2/10) · APIStatusError', + ); + }); + + it('rounds sub-second delays up to 1s', () => { + expect(formatStepRetryLabel(retry({ delayMs: 500 }))).toContain('in 1s'); + }); +}); + +describe('formatStepRetryDetail', () => { + it('prefixes the message with the status code', () => { + expect(formatStepRetryDetail(retry())).toBe('429 · rate limited'); + }); + + it('omits the status code for network/timeout failures', () => { + expect( + formatStepRetryDetail( + retry({ errorName: 'APIConnectionError', errorMessage: 'fetch failed', statusCode: undefined }), + ), + ).toBe('fetch failed'); + }); + + it('collapses multi-line error bodies into one line', () => { + expect(formatStepRetryDetail(retry({ errorMessage: 'line one\n\n line two' }))).toBe( + '429 · line one line two', + ); + }); + + it('caps huge error bodies', () => { + const detail = formatStepRetryDetail(retry({ errorMessage: 'x'.repeat(1000) })); + expect(detail.length).toBe(RETRY_DETAIL_MAX_CHARS); + expect(detail.endsWith('…')).toBe(true); + }); + + it('returns the status code alone when the message is empty', () => { + expect(formatStepRetryDetail(retry({ errorMessage: '' }))).toBe('429'); + }); +}); diff --git a/apps/kimi-code/test/utils/git/git-status.test.ts b/apps/kimi-code/test/utils/git/git-status.test.ts index 951816fd220..962bd8aa19c 100644 --- a/apps/kimi-code/test/utils/git/git-status.test.ts +++ b/apps/kimi-code/test/utils/git/git-status.test.ts @@ -1,9 +1,10 @@ /* eslint-disable import/first -- vi.mock setup must run before the imports it stubs out. */ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ spawnSync: vi.fn(), execFile: vi.fn(), + resolveCommandPath: vi.fn(), })); vi.mock('node:child_process', () => ({ @@ -11,8 +12,16 @@ vi.mock('node:child_process', () => ({ spawnSync: mocks.spawnSync, })); +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, +})); + import { createGitStatusCache, formatGitBadge } from '#/utils/git/git-status'; +beforeEach(() => { + mocks.resolveCommandPath.mockImplementation((command: string) => `/usr/bin/${command}`); +}); + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); @@ -200,6 +209,47 @@ describe('git status cache', () => { }); }); + it('returns null without spawning when git cannot be resolved to a safe path', () => { + mocks.resolveCommandPath.mockReturnValue(undefined); + expect(createGitStatusCache('/tmp/repo').getStatus()).toBeNull(); + expect(mocks.spawnSync).not.toHaveBeenCalled(); + expect(mocks.execFile).not.toHaveBeenCalled(); + }); + + it('spawns git and gh through their resolved absolute paths', async () => { + mocks.execFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback(new Error('no pull request'), '', ''); + }, + ); + mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('rev-parse')) return { status: 0, stdout: 'true\n' }; + if (args.includes('branch')) return { status: 0, stdout: 'main\n' }; + if (args.includes('status')) return { status: 0, stdout: '## main...origin/main\n' }; + return { status: 1, stdout: '' }; + }); + + const cache = createGitStatusCache('/tmp/repo'); + expect(cache.getStatus()).not.toBeNull(); + await Promise.resolve(); + + expect(mocks.resolveCommandPath).toHaveBeenCalledWith('git', '/tmp/repo'); + for (const call of mocks.spawnSync.mock.calls) { + expect(call[0]).toBe('/usr/bin/git'); + } + expect(mocks.execFile).toHaveBeenCalledWith( + '/usr/bin/gh', + expect.any(Array), + expect.anything(), + expect.any(Function), + ); + }); + it('returns null when the working directory is not a git repo and formats badges', () => { mocks.spawnSync.mockReturnValue({ status: 1, stdout: '' }); expect(createGitStatusCache('/tmp/not-a-repo').getStatus()).toBeNull(); diff --git a/apps/kimi-code/test/utils/process/fd-detect.test.ts b/apps/kimi-code/test/utils/process/fd-detect.test.ts index cd6fd249cc0..76e48b71aa3 100644 --- a/apps/kimi-code/test/utils/process/fd-detect.test.ts +++ b/apps/kimi-code/test/utils/process/fd-detect.test.ts @@ -7,6 +7,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { detectFdPath, getFdAssetName } from '#/utils/process/fd-detect'; import { getBinDir } from '#/utils/paths'; +const mocks = vi.hoisted(() => ({ + resolveCommandPath: vi.fn(), + spawnSync: vi.fn(), +})); + +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, +})); +vi.mock('node:child_process', () => ({ spawnSync: mocks.spawnSync })); + const originalEnv = { ...process.env }; let tempHome: string | undefined; @@ -16,6 +26,7 @@ afterEach(() => { tempHome = undefined; } process.env = { ...originalEnv }; + vi.clearAllMocks(); vi.unstubAllGlobals(); }); @@ -43,6 +54,20 @@ describe('getFdAssetName', () => { }); describe('detectFdPath', () => { + it('returns the absolute resolved path for a system fd binary', () => { + tempHome = mkdtempSync(join(tmpdir(), 'kimi-fd-home-')); + process.env['KIMI_CODE_HOME'] = tempHome; + mocks.resolveCommandPath.mockImplementation((name: string) => + name === 'fd' ? '/usr/local/bin/fd' : undefined, + ); + mocks.spawnSync.mockReturnValue({ status: 0 }); + + expect(detectFdPath()).toBe('/usr/local/bin/fd'); + expect(mocks.spawnSync).toHaveBeenCalledWith('/usr/local/bin/fd', ['--version'], { + stdio: 'ignore', + }); + }); + it('prefers the managed fd binary under KIMI_CODE_HOME', () => { tempHome = mkdtempSync(join(tmpdir(), 'kimi-fd-home-')); process.env['KIMI_CODE_HOME'] = tempHome; diff --git a/apps/kimi-code/test/utils/process/resolve-command.test.ts b/apps/kimi-code/test/utils/process/resolve-command.test.ts new file mode 100644 index 00000000000..8c836b45ff3 --- /dev/null +++ b/apps/kimi-code/test/utils/process/resolve-command.test.ts @@ -0,0 +1,147 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveCommandPath } from '#/utils/process/resolve-command'; + +const originalEnv = { ...process.env }; +const originalPlatform = process.platform; +let tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; + process.env = { ...originalEnv }; + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function mockPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }); +} + +describe('resolveCommandPath (posix)', () => { + // Executable-bit checks only work on a posix host. + it.skipIf(process.platform === 'win32')('resolves an executable from PATH to an absolute path', () => { + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + const tool = join(bin, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = bin; + + expect(resolveCommandPath('mytool', cwd)).toBe(tool); + }); + + it.skipIf(process.platform === 'win32')('ignores PATH files without the executable bit', () => { + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + writeFileSync(join(bin, 'mytool'), '#!/bin/sh\nexit 0\n'); + chmodSync(join(bin, 'mytool'), 0o644); + process.env['PATH'] = bin; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit inside the current working directory', () => { + const cwd = makeTempDir('kimi-resolve-cwd-'); + const tool = join(cwd, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + // The cwd itself sits on PATH (e.g. a `.` entry) — the planted binary + // must be rejected, not executed. + process.env['PATH'] = cwd; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit from a relative PATH entry landing in the cwd', () => { + const cwd = makeTempDir('kimi-resolve-cwd-'); + const tool = join(cwd, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = '.'; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit in a subdirectory of the cwd', () => { + const cwd = makeTempDir('kimi-resolve-cwd-'); + const nested = join(cwd, 'bin'); + mkdirSync(nested); + const tool = join(nested, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = nested; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it('returns undefined when the command is not on PATH', () => { + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + process.env['PATH'] = bin; + + expect(resolveCommandPath('definitely-not-a-real-command', cwd)).toBeUndefined(); + }); +}); + +describe('resolveCommandPath (win32)', () => { + it('resolves a bare name through PATHEXT', () => { + mockPlatform('win32'); + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + // Windows is case-insensitive, so the resolved name carries the PATHEXT + // casing; match it here so the test also passes on case-insensitive + // posix filesystems. + const shim = join(bin, 'npm.CMD'); + writeFileSync(shim, '@echo off\r\n'); + process.env['PATH'] = bin; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm', cwd)).toBe(shim); + }); + + it('tries an explicitly suffixed name as-is', () => { + mockPlatform('win32'); + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + const shim = join(bin, 'npm.cmd'); + writeFileSync(shim, '@echo off\r\n'); + process.env['PATH'] = bin; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm.cmd', cwd)).toBe(shim); + }); + + it('falls back to the default PATHEXT when the variable is unset', () => { + mockPlatform('win32'); + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + const shim = join(bin, 'bun.EXE'); + writeFileSync(shim, 'MZ'); + process.env['PATH'] = bin; + delete process.env['PATHEXT']; + + expect(resolveCommandPath('bun', cwd)).toBe(shim); + }); + + it('refuses a hit inside the current working directory', () => { + mockPlatform('win32'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + writeFileSync(join(cwd, 'npm.cmd'), '@echo off\r\n'); + process.env['PATH'] = cwd; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm', cwd)).toBeUndefined(); + }); +}); diff --git a/apps/kimi-inspect/AGENTS.md b/apps/kimi-inspect/AGENTS.md index 23e74caf956..2cafed50110 100644 --- a/apps/kimi-inspect/AGENTS.md +++ b/apps/kimi-inspect/AGENTS.md @@ -10,7 +10,7 @@ A left icon rail (`src/components/NavRail.tsx`) switches top-level views: - **Global message search** (`src/components/SearchView.tsx`) — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index). - **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies. Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. - **App Services** (`src/components/AppServicesView.tsx`) — the app-scope Service reflection, full width, joined by the **Workspace Services** view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`. -- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as a hand-rolled SVG, the cascade history, and the waiting area; the four panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix. +- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugEventsService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as Miller columns (`di/DiGraphPanel.tsx`), the event-subscription ledger (unit-book `on:` entries + per-bus listener counts, `di/DiEventsPanel.tsx`), the cascade history, and the waiting area; the five panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix. The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: diff --git a/apps/kimi-inspect/src/channel/channel.test.ts b/apps/kimi-inspect/src/channel/channel.test.ts index 1064924feaf..ff3d6bfad6e 100644 --- a/apps/kimi-inspect/src/channel/channel.test.ts +++ b/apps/kimi-inspect/src/channel/channel.test.ts @@ -31,14 +31,14 @@ describe('ProxyChannel.call', () => { it('POSTs the command to the service base URL; no body and no header without args/token', async () => { const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' })); const channel = new ProxyChannel({ - baseUrl: 'http://h:1/api/v1/debug/session/s%201/agent/main/agentRPCService', + baseUrl: 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService', fetch: fetchImpl, }); const result = await channel.call('getModel', []); expect(result).toEqual({ id: 's1' }); expect(calls).toHaveLength(1); expect(calls[0]!.url).toBe( - 'http://h:1/api/v1/debug/session/s%201/agent/main/agentRPCService/getModel', + 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService/getModel', ); expect(calls[0]!.init?.method).toBe('POST'); expect(calls[0]!.init?.body).toBeUndefined(); diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts index f0149efb1a5..a500fdc57b0 100644 --- a/apps/kimi-inspect/src/channel/client.ts +++ b/apps/kimi-inspect/src/channel/client.ts @@ -8,7 +8,7 @@ * await client.core(ISessionIndex).listRecent({}); * await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1'); * await client.session('s1').service(ISessionMetadata).read(); - * await client.session('s1').agent('main').service(IAgentRPCService).cancel({}); + * await client.session('s1').agent('main').service(IAgentLoopService).cancelFromUser(); * * The `agent-core-v2` service token is the whole key: its type parameter `T` * types the returned proxy, and its decorator id (`String(id)`) is the channel diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx index 2ba937a2e3c..25d50369b11 100644 --- a/apps/kimi-inspect/src/components/ChatView.tsx +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -14,12 +14,14 @@ * a full REST refresh; nothing is resynced from the socket itself. * * Rendering is turn-granular (turn → step → frame) and typed entirely by the - * transcript data model. Prompts/cancels go through the `IAgentRPCService` + * transcript data model. Prompts/cancels go through the `IAgentPromptService` + * / `IAgentLoopService` channels * over the debug RPC surface (`/api/v1/debug`); the running indicator * derives from transcript state (`meta.activity` / running turns). */ -import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; +import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; +import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionQuestionService, @@ -561,8 +563,8 @@ export function ChatView({ await klient .session(sessionId) .agent(agentId) - .service(IAgentRPCService) - .prompt({ input: [{ type: 'text', text }] }); + .service(IAgentPromptService) + .submit({ input: [{ type: 'text', text }] }); trail?.recordEvent('prompt', text, state); } catch (error) { setSendError(error); @@ -572,7 +574,7 @@ export function ChatView({ const cancel = async () => { if (sessionId === null) return; try { - await klient.session(sessionId).agent(agentId).service(IAgentRPCService).cancel({}); + await klient.session(sessionId).agent(agentId).service(IAgentLoopService).cancelFromUser(); trail?.recordEvent('cancel', undefined, state); } catch (error) { setSendError(error); diff --git a/apps/kimi-inspect/src/components/DiInspectionView.tsx b/apps/kimi-inspect/src/components/DiInspectionView.tsx index bd1f78476e4..93001b1b708 100644 --- a/apps/kimi-inspect/src/components/DiInspectionView.tsx +++ b/apps/kimi-inspect/src/components/DiInspectionView.tsx @@ -12,12 +12,16 @@ * with that service's direct dependencies; rows carry path-scoped * relation bars (one per path ancestor with a direct edge) and a * path-root background highlight; + * - Events: event subscriptions (`IDebugEventsService.subscriptions`) — + * unit-book ledger entries labeled `on:` / + * `disposable:EventSubscription` per scope, plus per-bus listener counts + * as the fallback side (`di/DiEventsPanel.tsx`); * - Cascade: the cross-scope cascade history rings * (`IDebugCascadeService.history`), newest first; * - Pending: the waiting area + sticky failures per scope * (`IDebugCascadeService.pending`), with an `update` retry per failure. * - * All four panels poll on a short interval and refresh eagerly when the + * All five panels poll on a short interval and refresh eagerly when the * global `event.di.unit_changed` WS frame fires (`useDiQueryInvalidation` * invalidates the `['di']` query prefix). */ @@ -30,6 +34,10 @@ import { type DebugPendingGroup, } from '@moonshot-ai/agent-core-v2/debug/debugCascade'; import { IDebugGraphService, type DebugGraph } from '@moonshot-ai/agent-core-v2/debug/debugGraph'; +import { + IDebugEventsService, + type DebugEventSubscriptions, +} from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents'; import { IDebugLedgerService, type DebugLedgerNode, @@ -42,13 +50,15 @@ import { useDiQueryInvalidation } from '../activity/di'; import type { InspectClient } from '../channel'; import { useConnection } from '../connection'; import { ActionButton, Badge, ErrorLine } from '../ui'; +import { DiEventsPanel } from './di/DiEventsPanel'; import { DiGraphPanel } from './di/DiGraphPanel'; -type DiPanel = 'units' | 'graph' | 'cascade' | 'pending'; +type DiPanel = 'units' | 'graph' | 'events' | 'cascade' | 'pending'; const PANELS: readonly { id: DiPanel; title: string }[] = [ { id: 'units', title: 'Units' }, { id: 'graph', title: 'Deps' }, + { id: 'events', title: 'Events' }, { id: 'cascade', title: 'Cascade' }, { id: 'pending', title: 'Pending' }, ]; @@ -86,6 +96,8 @@ export function DiInspectionView() { ) : panel === 'graph' ? ( + ) : panel === 'events' ? ( + ) : panel === 'cascade' ? ( ) : ( @@ -384,6 +396,19 @@ function GraphPanel() { return ; } +// --------------------------------------------------------------------------- +// Events panel — event subscriptions; rendering lives in di/DiEventsPanel.tsx +// --------------------------------------------------------------------------- + +function EventsPanel() { + const query = useDiQuery('events', (klient) => + klient.core(IDebugEventsService).subscriptions(), + ); + const gate = panelGate(query); + if (gate !== null) return gate; + return ; +} + // --------------------------------------------------------------------------- // Cascade panel — the cross-scope cascade history rings, newest first // --------------------------------------------------------------------------- diff --git a/apps/kimi-inspect/src/components/di/DiEventsPanel.tsx b/apps/kimi-inspect/src/components/di/DiEventsPanel.tsx new file mode 100644 index 00000000000..790dc6f389a --- /dev/null +++ b/apps/kimi-inspect/src/components/di/DiEventsPanel.tsx @@ -0,0 +1,146 @@ +/** + * DI Events panel — event-subscription introspection + * (`IDebugEventsService.subscriptions`), two merged sides: + * + * - Subscriptions: the unit-book side — every materialized unit's ledger + * entries labeled as an event subscription (`on:` from a named + * Emitter or the fiber `on` capability, `disposable:EventSubscription` + * from an unnamed one), grouped by scope path; + * - Bus listeners: the emitter-side fallback — per-`IEventBus` listener + * counts (`*` = the full stream) plus the global `IEventService` count, + * which also cover subscriptions never registered on a unit book. + * + * Pure React + Tailwind. + */ +import type { + DebugEventBusSnapshot, + DebugEventSubscription, + DebugEventSubscriptions, +} from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents'; + +import { Badge } from '../../ui'; + +const KIND_TONES: Record = { + disposer: 'neutral', + effect: 'sky', + ledger: 'violet', +}; + +export function DiEventsPanel({ data }: { data: DebugEventSubscriptions }) { + const groups = groupByScope(data.subscriptions); + return ( +
+
+ subscriptions ({data.subscriptions.length}) +
+ {groups.length === 0 ? ( +
+ no event subscriptions on any unit book +
+ ) : ( + groups.map(([scopePath, subs]) => ( +
+
+ {scopePath} + {subs.length} +
+
+ {subs.map((sub, i) => ( +
+ + {sub.unit} + + {sub.uid !== undefined ? ( + #{sub.uid} + ) : null} + + {sub.label} + + + {sub.kind} + +
+ ))} +
+
+ )) + )} +
+ bus listeners +
+ {data.buses.length === 0 && data.globalListeners === undefined ? ( +
no materialized event buses
+ ) : ( +
+ {data.globalListeners !== undefined ? ( + + ) : null} + {data.buses.flatMap((bus) => busRows(bus))} +
+ )} +
+ ); +} + +function groupByScope( + subs: readonly DebugEventSubscription[], +): [string, DebugEventSubscription[]][] { + const map = new Map(); + for (const sub of subs) { + const group = map.get(sub.scopePath) ?? []; + group.push(sub); + map.set(sub.scopePath, group); + } + return [...map.entries()]; +} + +function busRows(bus: DebugEventBusSnapshot) { + const rows = [ + , + ]; + for (const type of Object.keys(bus.perType).toSorted()) { + rows.push( + , + ); + } + return rows; +} + +function BusRow({ + scopePath, + type, + count, +}: { + scopePath: string; + type: string; + count: number; +}) { + return ( +
+ + {scopePath} + + + {type} + + {count} +
+ ); +} diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index 51e66304a4e..ce241fbbec1 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -23,8 +23,7 @@ import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/pe import { IAgentPermissionRulesService } from '@moonshot-ai/agent-core-v2/agent/permissionRules/permissionRules'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; -import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; -import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/agent/swarm/swarm'; +import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/features/swarm/agent/swarm'; import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; import { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agent/tokenCounting/tokenCounting'; import { IAgentToolRegistryService } from '@moonshot-ai/agent-core-v2/agent/toolRegistry/toolRegistry'; @@ -36,7 +35,7 @@ import { IProviderService } from '@moonshot-ai/agent-core-v2/kosong/provider/pro import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; -import { ISessionInitService } from '@moonshot-ai/agent-core-v2/session/sessionInit/sessionInit'; +import { ISessionInitService } from '@moonshot-ai/agent-core-v2/features/sessionInit/sessionInit'; import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; import { ISessionWorkspaceContext } from '@moonshot-ai/agent-core-v2/session/workspaceContext/workspaceContext'; @@ -269,17 +268,4 @@ export const AGENT_PANELS: readonly ServicePanelDef[] = [ { label: 'exit', run: (svc) => call(svc, 'exit') }, ], }, - { - id: String(IAgentRPCService), - label: 'AgentRPCService', - scope: 'agent', - actions: [ - { label: 'cancel turn', run: (svc) => call(svc, 'cancel', {}) }, - { - label: 'undoHistory', - input: 'Steps', - run: (svc, n) => call(svc, 'undoHistory', { count: Number(n) }), - }, - ], - }, ]; diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 2e6398613aa..a2ff988f7a8 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.6.9 + +### Patch Changes + +- Updated dependencies [[`c9bfe8b`](https://github.com/MoonshotAI/kimi-code/commit/c9bfe8b2c8314ba4ef8806fb3b92ac654c1d1860), [`c212ae9`](https://github.com/MoonshotAI/kimi-code/commit/c212ae9715371c0d7939c15e664acbe0d7cf7fc3)]: + - @moonshot-ai/kimi-code-sdk@0.17.0 + +## 0.6.8 + +### Patch Changes + +- Updated dependencies [[`437a1b8`](https://github.com/MoonshotAI/kimi-code/commit/437a1b8ba1b7e0f6662bdadc669564fdc58c3f5a), [`0b2e803`](https://github.com/MoonshotAI/kimi-code/commit/0b2e803d5e71afaab45212bb2ee6117ecbf8bbc9), [`3c9e3b2`](https://github.com/MoonshotAI/kimi-code/commit/3c9e3b297cf5286c761159c1b4d642c478fd394d)]: + - @moonshot-ai/kimi-code-sdk@0.16.0 + ## 0.6.7 ### Patch Changes diff --git a/apps/vscode/package.json b/apps/vscode/package.json index cfc06a92532..a474840e0a6 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -3,7 +3,7 @@ "publisher": "moonshot-ai", "displayName": "Kimi Code", "description": "Official Kimi Code plugin for VS Code", - "version": "0.6.7", + "version": "0.6.9", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 58ccd8acf1b..eac402b4bf0 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -57,6 +57,7 @@ const config = withMermaid(defineConfig({ { text: '使用目标模式', link: '/zh/guides/goals' }, { text: 'Supermoon 模式', link: '/zh/guides/supermoon-mode' }, { text: '在 IDE 中使用', link: '/zh/guides/ides' }, + { text: '本地服务与 API', link: '/zh/guides/server' }, ], }, ], @@ -67,7 +68,7 @@ const config = withMermaid(defineConfig({ { text: 'Model Context Protocol', link: '/zh/customization/mcp' }, { text: 'Agent Skills', link: '/zh/customization/skills' }, { text: 'Plugins', link: '/zh/customization/plugins' }, - { text: 'Agent 与子 Agent', link: '/zh/customization/agents' }, + { text: 'Agent 与 subagent', link: '/zh/customization/agents' }, { text: 'Hooks', link: '/zh/customization/hooks' }, { text: '自定义主题', link: '/zh/customization/themes' }, ], @@ -91,6 +92,7 @@ const config = withMermaid(defineConfig({ items: [ { text: 'kimi 命令', link: '/zh/reference/kimi-command' }, { text: 'kimi acp 子命令', link: '/zh/reference/kimi-acp' }, + { text: '服务 API', link: '/zh/reference/server-api' }, { text: '内置工具', link: '/zh/reference/tools' }, { text: '斜杠命令', link: '/zh/reference/slash-commands' }, { text: '键盘快捷键', link: '/zh/reference/keyboard' }, @@ -135,6 +137,7 @@ const config = withMermaid(defineConfig({ { text: 'Using Goals', link: '/en/guides/goals' }, { text: 'Supermoon Mode', link: '/en/guides/supermoon-mode' }, { text: 'Using in IDEs', link: '/en/guides/ides' }, + { text: 'Local Server and API', link: '/en/guides/server' }, ], }, ], @@ -169,6 +172,7 @@ const config = withMermaid(defineConfig({ items: [ { text: 'kimi Command', link: '/en/reference/kimi-command' }, { text: 'kimi acp Subcommand', link: '/en/reference/kimi-acp' }, + { text: 'Server API', link: '/en/reference/server-api' }, { text: 'Built-in Tools', link: '/en/reference/tools' }, { text: 'Slash Commands', link: '/en/reference/slash-commands' }, { text: 'Keyboard Shortcuts', link: '/en/reference/keyboard' }, diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 86f36083d3b..c03f4439110 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -67,8 +67,8 @@ Term mapping (Chinese <-> English, and proper noun handling): | Chinese | English | Proper noun (zh) | Proper noun (en) | | --- | --- | --- | --- | | Agent | agent | yes | no | -| 主 Agent | main agent | yes (Agent) | no | -| 子 Agent | subagent | yes (Agent) | no | +| main agent | main agent | no | no | +| subagent | subagent | no | no | | Shell | shell | yes | no | | Plan 模式 | Plan mode | yes | yes (Plan mode) | | YOLO 模式 | YOLO mode | yes | yes (YOLO mode) | diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 2a494973328..71c759b4599 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -196,34 +196,72 @@ You can also switch models temporarily without touching the config file — by s ## `secondary_model` -The secondary model is a second model configuration alongside the main model — typically a cheaper one, for features that do not need the main model's capability. Its consumer today is subagent spawning: when set, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model; when unset, subagents inherit the main agent's model. +Subagents inherit the model the main agent is running by default. The `[secondary_model]` section makes this configurable: it offers subagents a pool of candidate models plus a default binding — typically a cheaper model for subtasks that do not need the main model's capability. -This is a default binding, not a forced one. With the experiment enabled, the `Agent` / `AgentSwarm` tools gain a `model` parameter (accepting only the symbolic values `"secondary"` / `"primary"`), and the tool description lists the available models with the default marked. A spawn resolves the subagent's model in this order: an explicit tool-call `model` → the profile's [`model_preference`](../customization/agents.md#agent-file-format) → the configured secondary model (the default). Here `"primary"` means the model the main agent is currently running, not necessarily `default_model` — for example after a mid-session `/model` switch. +### Subagent model pool -Because overriding the default is the main agent's own decision (the tool description merely suggests `"secondary"` for routine tasks and `"primary"` for hard, quality-sensitive ones), there is no per-spawn switch on the user side. To steer a specific subagent to the main model, ask the main agent in your prompt to pass `model: "primary"`, or set `model_preference: "primary"` in the corresponding profile. +This feature is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. While the experiment is off, the pool keys stay inert: subagents inherit the caller's model and session startup skips the pool validation. -This feature is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. +To simply point every subagent at one model by default, no models table is needed — a single `default_model` line is a pool with a single entry: -In the interactive TUI, the [`/secondary_model`](../reference/slash-commands.md) command opens a model picker that writes this section and live-applies it to the current session, so newly spawned subagents bind the new secondary model right away. +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +``` + +In the interactive TUI, the [`/secondary-model`](../reference/slash-commands.md) command (alias `/subagent-model`) opens a model selector for this: the choice is written to `default_model` (when a models table exists and the picked alias is not in it, an entry with an empty description is added), and newly spawned subagents pick up the new default immediately — no session restart needed. | Field | Type | Default | Description | | --- | --- | --- | --- | -| `model` | `string` | — | The alias of a configured [`[models]`](#models) entry, e.g. `kimi-code/kimi-k2.5` (any provider, not limited to Kimi models) | -| `default_effort` | `string` | — | Thinking effort applied when subagents bind to the secondary model. Unset, the effort resolves naturally (global `[thinking]` config → the bound model's default effort) instead of inheriting the main agent's effort. Follows the main model's thinking-effort semantics: models with strict effort validation (e.g. Kimi models) fall back to their default effort for unsupported values; other providers receive the value as-is | -| Other fields | — | — | Accepts every field of [`[models."".overrides]`](#models) (`max_context_size`, `max_output_size`, `support_efforts`, …) as a model patch applied only to subagents | +| `default_model` | `string` | — | Default subagent model. Required when `[secondary_model.models]` is configured, and must be one of its keys; written on its own (without a models table) it is equivalent to a pool containing only that entry | +| `models` | `table` | — | Subagent model pool. Each key is the alias of a configured [`[models]`](#models) entry; each value is the description the main agent sees when picking a subagent model (Chinese or English; an empty string lists the alias with no hint) | +| `force` | `boolean` | `false` | Pin every subagent to `default_model`: the `model` parameter is not advertised, so the main agent cannot pick another model or `"primary"`. Requires `default_model`; cannot be combined with `[secondary_model.models]` | + +A configured pool — an explicit `[secondary_model.models]` table or a lone `default_model` — enables model selection: the `Agent` / `AgentSwarm` tools gain a `model` parameter, and the tool description lists the pool (the default marked `[default]`) so the main agent can choose per spawn (unless `force` is set — see below). The pool only references configured [`[models]`](#models) entries — the `kimi-code/*` aliases below are provisioned by `/login` — and attaches the selection hints: + +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +[secondary_model.models] +"kimi-code/k3" = "Pick this for hard problems. Strong at complex reasoning, algorithm design, deep debugging, math, and systematic challenges." +"kimi-code/kimi-for-coding-highspeed" = "Fast and cheap. Good for daily refactoring, code explanation, small edits, summaries, and simple batch tasks." +"kimi-code/kimi-for-coding" = "A balanced coding workhorse. Good for most feature development and code-change tasks." +``` + +A spawn resolves the subagent's model in this order: an explicit tool-call `model` → `default_model`. The `model` parameter accepts any pool alias, or `"primary"` — the model the caller itself is running, always valid even when that model is not in the pool. When neither `default_model` nor `[secondary_model.models]` is configured, the parameter is not advertised and subagents inherit the caller's model. Binding a pool alias carries no explicit thinking effort — the subagent resolves it naturally (global `[thinking]` config → the bound model's default effort) instead of inheriting the caller's level, while `"primary"` inherits both the model and the level from the caller. + +To take the choice away from the main agent entirely — every subagent runs on one fixed model — add `force = true`: -Every field besides `model` forms a patch: when at least one patch field is set, the runtime synthesizes a derived model entry in memory (a copy of the pointed entry with the patch merged into its overrides, patch winning conflicts) and subagents bind that derived entry; with no patch fields, subagents bind the pointed entry directly. The derived entry lives only in memory (never written back to `config.toml`) and is hidden from model-selection lists. +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +force = true +``` + +With `force` set, the `model` parameter is not advertised (just like when nothing is configured) and every spawn binds `default_model`; an explicit `model` argument, `"primary"` included, is rejected with an error. `force` requires `default_model` and cannot be combined with a `[secondary_model.models]` table — the table exists to offer a choice, and force removes it. + +Because natural resolution lands on the bound model's default effort, different pool entries can carry different thinking levels: register a second `[models]` entry as a "variant" of the same underlying model, override only its `default_effort` via [`[models."".overrides]`](#model-overrides), and list both aliases in the pool — the main agent picks the thinking level together with the alias: ```toml +# "kimi-code/kimi-for-coding-highspeed" is provisioned by /login; this +# registers a higher-effort variant of the same model +[models.kimi-for-coding-highspeed-deep] +provider = "managed:kimi-code" +model = "kimi-for-coding-highspeed" + +[models.kimi-for-coding-highspeed-deep.overrides] +default_effort = "high" + [secondary_model] -model = "kimi-code/kimi-k2.5" -default_effort = "low" -max_output_size = 8192 +default_model = "kimi-code/kimi-for-coding-highspeed" +[secondary_model.models] +"kimi-code/kimi-for-coding-highspeed" = "Fast and cheap. Good for daily refactoring, code explanation, small edits, summaries, and simple batch tasks." +kimi-for-coding-highspeed-deep = "The same model at a high thinking level. Good for harder subtasks." ``` -`model` / `default_effort` can be overridden by the `KIMI_SECONDARY_MODEL` / `KIMI_SECONDARY_EFFORT` environment variables, which take higher priority than `config.toml`. +Note that `default_effort` stays a model-level default: once a global `[thinking].effort` is set, it wins for the main agent and subagents alike, and the variant's default only applies when no global effort is set. Value and fallback rules follow the [`[models]` entry's `default_effort`](#models). -When the experiment is enabled, the configuration is validated as the session starts: an unresolvable `model`, or a `default_effort` not listed by the (patched) model, produces a startup warning (also returned by the session-warnings API). The check is advisory — a broken secondary model still fails at spawn time, with the same source hint attached to the spawn error. +Configuration errors fail loudly instead of falling back silently: session creation, resume, and fork all fail at startup when `default_model` is missing, is not a pool key, or a pool key does not resolve to a configured `[models]` entry — and likewise when `force` is set without `default_model` or combined with a `[secondary_model.models]` table. The alias `primary` is reserved — it always binds the caller's own model — and is rejected as a pool key. A spawn whose `model` is neither a pool alias nor `"primary"` fails with an error listing the available choices. ## `thinking` @@ -289,9 +327,12 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent ## `subagent` +`subagent` controls how spawned subagents (`Agent` / `AgentSwarm`) run. + | Field | Type | Default | Description | | --- | --- | --- | --- | | `timeout_ms` | `integer` | `7200000` (2 hours) | Maximum wall-clock time (milliseconds) a single subagent (`Agent` / `AgentSwarm`) is allowed to run before it is settled as `timed_out`. `0` means no timeout — the subagent runs until it finishes or the model stops it. This is the background-task manager's per-task timeout for each subagent task, so it applies to both foreground and background subagents. In print mode (`kimi -p`) the default is `0` unless explicitly set. Note: any value above `2147483647` (about 24.8 days) is clamped to roughly 24.8 days by the runtime | + `timeout_ms` can be overridden by the `KIMI_SUBAGENT_TIMEOUT_MS` environment variable, which takes higher priority than `config.toml`. ## `mcp` @@ -459,6 +500,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | Field | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes.md) | +| `render_latex` | `boolean` | `true` | Render LaTeX math expressions (`$…$`, `$$…$$`) in Markdown messages as Unicode text; `false` keeps the raw source | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | Show a dialog when resuming a long-idle session or submitting after a long idle stretch, warning that the context cache has likely expired and offering to compact or start a new session (v2 engine only) | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | @@ -471,6 +513,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c ```toml # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | custom theme name +render_latex = true # false keeps LaTeX math in messages as raw source disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 4e519dda47f..fb3fb86ee34 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -121,6 +121,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | Variable | Purpose | Valid values | | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) | +| `KIMI_CODE_PASSWORD` | Set a parallel auth credential for the `kimi web` local server, valid alongside the bearer token; recommended when binding the server beyond loopback — see [Local server and API](../guides/server.md#authentication) | Any non-empty string; when unset, only the token is valid | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` | Cap on concurrently running background tasks; takes higher priority than `[background] max_running_tasks` in `config.toml` (unset means no cap) | Positive integer; invalid values are ignored | | `KIMI_IMAGE_MAX_EDGE_PX` | Longest-edge ceiling (px) for image compression; takes higher priority than `[image] max_edge_px` in `config.toml` (default `2000`) | Positive integer; invalid values are ignored | @@ -131,9 +132,8 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_IDENTITY_NAME` | Display name the agent calls itself in the system prompt; takes higher priority than `[identity] name` in `config.toml` and is never written back to it | Any non-empty string; blank values read as unset | | `KIMI_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Kimi Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental secondary-model feature in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | -| `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than [`[secondary_model] model`](./config-files.md#secondary-model) in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model | The alias of a configured `[models]` entry, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | -| `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled | An effort value, e.g. `low`; blank values are ignored | +| `KIMI_CODE_TUI_FULL_SCREEN` | Enable the experimental fullscreen alternate-screen UI: scrollable transcript viewport, mouse text selection, clickable links, and Ctrl-Shift-F transcript search | `1` enables it; anything else keeps the regular inline UI | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental [subagent model pool](./config-files.md#subagent-model-pool) in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 2b247a3a025..ae511883d5b 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -81,7 +81,6 @@ name: reviewer description: Strict code reviewer that reports severity-ranked findings whenToUse: Code reviews and PR checks override: false -model_preference: primary tools: - Read - Grep @@ -100,7 +99,6 @@ You are a strict code reviewer. Read the diff, then report findings grouped by s | `description` | yes | What the agent does. Shown to the main Agent when it picks a sub-agent, so write it to guide delegation decisions | | `whenToUse` | no | Extra hint describing when the agent should be used | | `override` | no | Whether this file may replace a same-name built-in Agent. Defaults to `false`; `--agent-file` is already explicit and does not require this field | -| `model_preference` | no | Symbolic default used when `Agent` or `AgentSwarm` spawns this profile: `primary` selects the model the caller is currently running, while `secondary` selects [`[secondary_model] model`](../configuration/config-files.md#secondary-model). An explicit tool-call `model` (which likewise accepts only `"primary"` / `"secondary"`) wins over this field; without either setting, the configured secondary model remains the default. If no secondary model is configured, the subagent inherits the caller's model | | `tools` | no | Allowlist of tool names such as `Read` or `Bash`; MCP tools are matched with globs such as `mcp__github__*`. Accepts a YAML list or a comma-separated string (`tools: Read, Grep`). Omit to allow all tools; a lone `*` also allows all tools; an empty list (`tools: []`) disables all tools | | `disallowedTools` | no | Denylist with the same syntax and matching rules, applied after `tools` | | `subagents` | no | Allowlist of sub-agent names this agent may delegate to, with the same syntax as `tools` (YAML list or comma-separated string). Omit to allow every type; a lone `*` also allows all types | @@ -111,8 +109,6 @@ The body is the agent's system prompt, and it is rendered as a template each tim Unknown fields are ignored, so newer files stay readable by older versions. Fields from other agent tools (such as Claude Code's `model` or OpenCode's `mode`) are ignored the same way, the comma-separated `tools` form keeps Claude Code-style agent files loadable, and a missing `name` falls back to the file name so OpenCode-style files load too — a minimal file with `description` and a body works across tools. -`model_preference` applies only to newly spawned subagents when the secondary-model experiment is enabled — set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. The field never names a concrete model alias, and resumed subagents keep their existing model. The selected preference is shown to the main agent alongside the profile description so it can still pass an explicit `model` when a task needs a different choice. - A file with invalid content discovered in a directory is skipped with a warning and does not affect other files. A file passed explicitly via `--agent-file` must be valid — otherwise the CLI reports the error and exits. ::: warning Note diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index a6533c38f6b..3ebcd4fab61 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -23,6 +23,8 @@ Run `/mcp-config` in the TUI to interactively add, edit, or delete servers witho Deleting a server from the configuration does not interrupt open sessions: the server stays listed in `/mcp` as `removed`, its tools remain visible there, and calls to them fail with a removal notice, while new sessions do not register the tools at all. Conversely, a server added mid-session — by editing `mcp.json` or installing a plugin — is not registered in already-open sessions; it only joins sessions created later. +When Kimi Code finds project-level MCP servers in an untrusted folder, it shows each server's transport and launch target in the workspace trust prompt. The prompt defaults to `Don't trust`; move to `Trust this folder` and confirm only after reviewing the listed command and arguments or remote URL. Trusting the folder enables the project-level MCP servers for that workspace. + Structure of `mcp.json`: ```json diff --git a/docs/en/guides/server.md b/docs/en/guides/server.md new file mode 100644 index 00000000000..1df4d46741a --- /dev/null +++ b/docs/en/guides/server.md @@ -0,0 +1,116 @@ +# Local Server and API + +Kimi Code CLI ships with a built-in local server: running `kimi web` starts a foreground process that mounts three things at once — the web UI in your browser, a REST API (`/api/v1`), and a WebSocket event stream (`/api/v1/ws`). The web UI lets you use Kimi Code in a browser; the REST and WebSocket APIs are for scripts and third-party tools, letting you create sessions, submit prompts, and follow execution from code — all reading and writing the same session data as the TUI and the web UI. + +> Make sure Kimi Code CLI is installed and ready to use first — either logged in via `/login` (in the TUI, or `kimi login`), or with a provider configured in `config.toml`. The server shares the CLI's login state and configuration, so no separate credential is needed for it. + +::: warning +The REST and WebSocket APIs described on this page are experimental: interface stability is not guaranteed, and endpoints, fields, and event types may change in any release. When integrating, rely on the `/openapi.json` and `/asyncapi.json` documents served by your version. +::: + +## Start the server + +```sh +kimi web # run the server in the foreground and open the browser +kimi web --no-open # run the server only, don't open the browser +kimi web --port 58628 # pick a specific bind port +``` + +The server binds to `127.0.0.1:58627` by default (loopback only). If the port is taken it automatically retries with the next one, so multiple instances can coexist on the same machine; each instance registers under `~/.kimi-code/server/instances/`. The startup banner prints the access URL and the plaintext token: + +```text +Local: http://127.0.0.1:58627/#token=... +Token: ... +Stop: Ctrl+C +``` + +The server runs in the foreground; press `Ctrl-C` for a clean shutdown. For the full option list such as `--host` and `--log-level`, see the [kimi command reference](../reference/kimi-command.md#kimi-web). + +## Authentication + +Every `/api/*` endpoint requires a bearer token (any request carrying this string is treated as authorized). The token is generated on the first server boot, persisted at `~/.kimi-code/server.token` (file mode 0600), and reused across restarts. + +Pick the carrying method that fits your client: + +- **REST**: the `Authorization: Bearer ` request header. +- **web UI**: the URL in the startup banner carries a `#token=` fragment, so opening it in a browser completes sign-in automatically. The fragment is never sent to the server. +- **WebSocket**: clients that can set headers use `Authorization: Bearer`; clients that cannot (such as browsers) pass the subprotocol (a protocol name declared during the WebSocket handshake) `kimi-code.bearer.` instead. + +If the token leaks, run `kimi web rotate-token`: the new token is written to `server.token` immediately, the old one stops working at once, and running instances pick up the new token without a restart. + +If you bind the server to a non-loopback address (`--host`), also set the `KIMI_CODE_PASSWORD` environment variable as a parallel credential; the server then rate-limits authentication failures automatically. + +::: danger +`--dangerous-bypass-auth` disables authentication entirely — anyone who can reach the port can control your sessions, file system, and shell. Only use it on trusted networks or behind your own authenticating proxy. See the [kimi command reference](../reference/kimi-command.md#kimi-web). +::: + +## Drive a session over the API + +The minimal flow with curl: check the server → create a session → subscribe to events → submit a prompt → read history back. The examples assume the server runs at the default address and the token is stored in the shell variable `TOKEN`. + +1. Check server status: + +```sh +curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:58627/api/v1/meta +``` + +Every JSON response is wrapped in a uniform envelope — `{ "code": 0, "msg": "success", "data": ..., "request_id": "..." }`. The business outcome lives in `code` (`0` means success); the HTTP status only reports transport-level results. + +2. Create a session; `metadata.cwd` sets the working directory: + +```sh +curl -s -X POST http://127.0.0.1:58627/api/v1/sessions \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"metadata": {"cwd": "/path/to/project"}}' +``` + +The returned `data.id` (shaped like `session_...`) is the session id used by every subsequent request. + +3. Connect to the WebSocket and subscribe to session events. Any WebSocket client works; below is a dependency-free Node.js script (Node.js 22+ ships a built-in `WebSocket` client): + +```js +// subscribe.mjs — usage: TOKEN=... node subscribe.mjs session_... +const ws = new WebSocket('ws://127.0.0.1:58627/api/v1/ws', [ + `kimi-code.bearer.${process.env.TOKEN}`, +]); +ws.onmessage = (e) => console.log(e.data); +ws.onopen = () => + ws.send( + JSON.stringify({ + type: 'subscribe', + id: '1', + payload: { session_ids: [process.argv[2]] }, + }), + ); +``` + +4. Submit a prompt: + +```sh +curl -s -X POST http://127.0.0.1:58627/api/v1/sessions//prompts \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"content": [{"type": "text", "text": "Introduce this repository in one sentence"}]}' +``` + +The subscriber sees, in order: `turn.started` (turn begins) → `assistant.delta` (streaming text increments) → `tool.call.started` / `tool.result` when tool calls happen → `turn.ended` (turn finishes). + +5. Read history back over REST at any time: + +```sh +curl -s -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:58627/api/v1/sessions//messages?page_size=20" +``` + +## Live specification documents + +While running, the server describes itself with two specification documents, both requiring the bearer token: + +- `GET /openapi.json` — an OpenAPI document for the REST API, with request/response schemas for every endpoint; import it into Swagger UI, Postman, and similar tools. +- `GET /asyncapi.json` — an AsyncAPI document for the WebSocket protocol, covering control frames and event types. + +## Next steps + +- [Server API](../reference/server-api.md) — full REST endpoint inventory, error codes, WebSocket events, and the transcript protocol +- [kimi command](../reference/kimi-command.md#kimi-web) — all `kimi web` command-line options diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 36480081d2e..238d741fbde 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -157,7 +157,7 @@ kimi acp Run the local Kimi server in the foreground of the current terminal — a single process that exposes the REST + WebSocket API and serves the web UI from the same origin — and open the web UI in the default browser once it is ready. The command stays attached to the terminal and shuts down cleanly on `SIGINT` / `SIGTERM` (e.g. `Ctrl-C`). -When the server is running, `GET /openapi.json` returns the REST OpenAPI document and `GET /asyncapi.json` returns the local WebSocket AsyncAPI document. +When the server is running, `GET /openapi.json` returns the REST OpenAPI document and `GET /asyncapi.json` returns the local WebSocket AsyncAPI document. For an end-to-end walkthrough of driving sessions over the API, see [Local server and API](../guides/server.md); for the protocol details, see the [Server API](./server-api.md) reference. ```sh kimi web # run the server in the foreground and open the browser diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md new file mode 100644 index 00000000000..3a056830d5e --- /dev/null +++ b/docs/en/reference/server-api.md @@ -0,0 +1,344 @@ +# Server API + +The local server started by `kimi web` exposes two programmatic surfaces: a REST API (`/api/v1`, plus `/api/v2/sessions`) and a WebSocket event stream (`/api/v1/ws`). This page is the protocol reference for both. For how to start the server and its command-line options, see the [kimi command](./kimi-command.md#kimi-web) reference; for an end-to-end walkthrough, see [Local server and API](../guides/server.md). + +The complete request/response schema of every endpoint is owned by the server's live specification documents: `GET /openapi.json` (OpenAPI) and `GET /asyncapi.json` (AsyncAPI). Both require authentication. + +::: warning +The REST and WebSocket APIs described on this page are experimental: interface stability is not guaranteed, and endpoints, fields, and event types may change in any release. When integrating, rely on the `/openapi.json` and `/asyncapi.json` documents served by your version. +::: + +## Conventions + +### Address + +The default address is `http://127.0.0.1:58627`. When the port is taken, the server retries with the next port (up to 100 times); use `--port` / `--host` to change the bind. Multiple instances can coexist under the same home directory; running instances register under `~/.kimi-code/server/instances/`. + +### Authentication + +All `/api/*` paths (including `/openapi.json` and `/asyncapi.json`) require the bearer token, except: + +- `OPTIONS` preflight requests +- `GET /api/v1/healthz` (liveness probe) +- Static web assets (non-`/api/` paths) + +How to carry it: REST uses the `Authorization: Bearer ` header; the WebSocket upgrade accepts the same header or the subprotocol `kimi-code.bearer.`. Token generation and rotation are covered in [Local server and API: Authentication](../guides/server.md#authentication). + +Failed authentication returns HTTP 401 with envelope code `40101`. On non-loopback binds, a source that fails authentication 10 times within 60 seconds is banned for 60 seconds, during which every request gets HTTP 429 (code `42901`). + +### Response envelope + +Every JSON response is wrapped in a uniform envelope: + +```json +{ + "code": 0, + "msg": "success", + "data": {}, + "request_id": "01JZX4A6E7M8V0R3Q0N2K2M5Q9" +} +``` + +- `code`: the business outcome; `0` means success. See the error-code bands below. +- `data`: the payload on success. Note that some "error" envelopes also carry a non-null `data` — for example, resolving an already-resolved approval returns `40902` with `data.resolved` set to `false` — so clients should check `code` first, then `data`. +- `request_id`: a ULID for this request. Clients may supply one via the `X-Request-Id` header; invalid values are regenerated by the server. + +The HTTP status is almost always 200; the business outcome lives in `code`. Exceptions: + +| Situation | HTTP status | +| --- | --- | +| Authentication failure / rate limit | 401 / 429 | +| Provider created, provider catalog imported | 201 | +| Provider deleted | 204 | +| Binary/streaming endpoints | 206 (Range) / 304 (ETag unchanged) where supported — capabilities differ per endpoint, see [Binary and streaming endpoints](#binary-and-streaming-endpoints) | +| `GET /api/v1/files/{file_id}` download errors | real 404 / 500 (still carrying an envelope body) | + +The 201 responses still carry the standard envelope (`code` 0) — only the status line follows the REST convention for resource creation. A 204 response has no body by definition, so a successful delete is reported by the status code itself. + +### Error codes + +Error codes are grouped by band: + +| Band | Meaning | Examples | +| --- | --- | --- | +| `0` | Success | | +| `400xx` | Bad request | `40001` validation failed (`details` lists each field), `40003` provider is OAuth-managed | +| `401xx` | Auth and readiness | `40101` unauthorized, `40110` no provider configured, `40113` model not resolved | +| `404xx` | Not found | `40401` session, `40408` MCP server, `40409` file path | +| `409xx` | State conflict | `40901` session busy, `40902` approval already resolved, `40922` page conditions mismatch `page_token` | +| `410xx` | Expired | `41001` approval timed out, `41002` question timed out, `41003` temporary file expired | +| `413xx` | Size or boundary exceeded | `41302` file read over 10 MB, `41304` path escapes the session directory | +| `429xx` | Rate limited | `42901` auth-failure ban, `42902` too many fs watches | +| `500xx` | Server internal error | `50001` uncaught exception, `50003` persistence failure | +| `6xxxx` / `7xxxx` / `8xxxx` | Tool runtime / LLM provider / MCP passthrough errors; `msg` carries the upstream text | | + +### Pagination + +List endpoints come in two styles: + +- **Cursor style**: `before_id` / `after_id` (mutually exclusive) plus `page_size` (1–100), responding with `{ items, has_more }`. Used by the session list, message list, transcript, and others. +- **`page_token`**: an opaque token (bound to a fingerprint of the query conditions), used by `POST /api/v1/search` and `GET /api/v2/sessions`. Changing any query condition mid-pagination invalidates the token: v2 returns `40922`, search returns `40001`. + +## REST endpoints + +Endpoints are grouped by resource below. A `:{action}` suffix in a path is the action convention — POST to `path:action` on a single resource for non-CRUD operations (such as `:fork` and `:archive` on a session). + +### Server and metadata + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/healthz` | Liveness probe; auth-exempt | +| `GET /api/v1/meta` | Server version, capability map, `server_id`, experimental flags | +| `POST /api/v1/shutdown` | Graceful shutdown (replies 200 first); mounted only on loopback binds | + +### Login and usage + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/auth` | Auth readiness snapshot | +| `POST /api/v1/oauth/login` | Start the OAuth device-code login flow | +| `GET /api/v1/oauth/login` | Poll the login flow state | +| `DELETE /api/v1/oauth/login` | Cancel a pending login flow | +| `POST /api/v1/oauth/logout` | Log out the managed provider | +| `GET /api/v1/oauth/usage` | Plan usage and limits | +| `GET /api/v1/oauth/userinfo` | Account profile | + +### Config + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/config` | Read the global config (secret fields redacted) | +| `POST /api/v1/config` | Merge-patch the config; broadcasts `event.config.changed` | + +### Models and providers + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/models` | List configured model aliases | +| `POST /api/v1/models/{model_id}:set_default` | Set the global default model | +| `GET /api/v1/providers` | List providers | +| `POST /api/v1/providers` | Create a provider (201) | +| `GET /api/v1/providers/{provider_id}` | Read a provider (reveals the stored key) | +| `PUT /api/v1/providers/{provider_id}` | Replace a provider | +| `DELETE /api/v1/providers/{provider_id}` | Delete a provider (204) | +| `POST /api/v1/providers/{provider_id}:refresh` | Refresh one provider's model metadata | +| `POST /api/v1/providers:{action}` | Collection actions: `refresh` / `refresh_oauth` / `import_catalog` / `import_registry` | +| `GET /api/v1/catalog/providers` | Browse the models.dev directory (server-proxied) | +| `GET /api/v1/catalog/providers/{catalog_id}` | Read one directory entry | + +### Sessions + +| Method and path | Description | +| --- | --- | +| `POST /api/v1/sessions` | Create a session (requires `workspace_id` or `metadata.cwd`) | +| `GET /api/v1/sessions` | List sessions; cursor pagination with filters such as `busy` and `archived_only` | +| `GET /api/v1/sessions/{session_id}` | Read one session | +| `GET /api/v1/sessions/{session_id}/profile` | Read the session profile | +| `POST /api/v1/sessions/{session_id}/profile` | Update title, metadata, agent config | +| `POST /api/v1/sessions/{session_id}:{action}` | Session actions: `fork` / `compact` / `undo` / `abort` / `btw` / `archive` / `restore` | +| `GET /api/v1/sessions/{session_id}/children` | List child sessions | +| `POST /api/v1/sessions/{session_id}/children` | Create a child session (fork with a tag) | +| `GET /api/v1/sessions/{session_id}/status` | Realtime status rollup | +| `GET /api/v1/sessions/{session_id}/goal` | Current goal snapshot (`null` when none) | +| `GET /api/v1/sessions/{session_id}/warnings` | Session-level warnings | +| `POST /api/v1/sessions/{session_id}/export` | Export the session with diagnostics (zip stream, not enveloped) | +| `GET /api/v1/sessions/{session_id}/snapshot` | Full snapshot for client rebuilds (with `as_of_seq` and `epoch`) | + +### Messages and transcript + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/messages` | Page messages (`before_id` / `after_id` / `role`) | +| `GET /api/v1/sessions/{session_id}/messages/{message_id}` | Read one message | +| `GET /api/v1/sessions/{session_id}/transcript` | Turn-paged transcript (requires `agent_id`); global state rides along unpaginated | +| `GET /api/v1/sessions/{session_id}/transcript/ops` | Op-batch catch-up (`since_seq`); `complete: false` means a full refresh is needed | +| `GET /api/v1/sessions/{session_id}/transcript/user-messages` | Turn-opening user inputs, unpaginated | +| `GET /api/v1/sessions/{session_id}/transcript/plan` | ExitPlanMode plan content, path, and review outcome | + +### Prompts + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/prompts` | Active and queued prompts | +| `POST /api/v1/sessions/{session_id}/prompts` | Submit a prompt (content-part array, optional model / permission-mode overrides) | +| `POST /api/v1/sessions/{session_id}/prompts:steer` | Steer queued prompts into the active turn | +| `POST /api/v1/sessions/{session_id}/prompts/{prompt_id}:abort` | Abort a running prompt | +| `POST /api/v1/sessions/{session_id}/prompts/{prompt_id}:steer` | Steer one queued prompt | + +### Approvals and questions + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/approvals` | List approval requests (filter with `status=pending`) | +| `POST /api/v1/sessions/{session_id}/approvals/{approval_id}` | Resolve an approval | +| `GET /api/v1/sessions/{session_id}/questions` | List questions | +| `POST /api/v1/sessions/{session_id}/questions/{question_id}` | Answer a question | +| `POST /api/v1/sessions/{session_id}/questions/{question_id}:dismiss` | Dismiss a question | + +### Background tasks + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/tasks` | List background tasks | +| `GET /api/v1/sessions/{session_id}/tasks/{task_id}` | Read a task (optional output preview) | +| `POST /api/v1/sessions/{session_id}/tasks/{task_id}:cancel` | Cancel a task | + +### Skills, tools, and MCP + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/skills` | Per-session skill catalog | +| `GET /api/v1/workspaces/{workspace_id}/skills` | Session-less skill catalog for a workspace | +| `POST /api/v1/sessions/{session_id}/skills/{skill_name}:activate` | Activate a skill (starts a turn) | +| `GET /api/v1/tools` | List tools of the effective agent | +| `GET /api/v1/mcp/servers` | List MCP servers | +| `POST /api/v1/mcp/servers/{mcp_server_id}:restart` | Restart an MCP server | + +### Terminals + +PTY terminal endpoints; mounted only on loopback binds. + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/terminals` | List terminals | +| `POST /api/v1/sessions/{session_id}/terminals` | Create a terminal | +| `GET /api/v1/sessions/{session_id}/terminals/{terminal_id}` | Read a terminal (including scrollback) | +| `POST /api/v1/sessions/{session_id}/terminals/{terminal_id}:close` | Close a terminal | + +### Workspaces + +| Method and path | Description | +| --- | --- | +| `GET /api/v1/workspaces` | List registered workspaces | +| `POST /api/v1/workspaces` | Register a workspace (idempotent on the root path) | +| `PATCH /api/v1/workspaces/{workspace_id}` | Rename | +| `DELETE /api/v1/workspaces/{workspace_id}` | Unregister (keeps on-disk content) | +| `GET /api/v1/workspaces/{workspace_id}/trust` | Read the trust state | +| `POST /api/v1/workspaces/{workspace_id}/trust` | Grant trust | +| `POST /api/v1/workspaces/{workspace_id}/untrust` | Revoke trust | + +### File system + +In-session file operations go through `POST /api/v1/sessions/{session_id}/fs:{action}` with JSON bodies; actions are `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `diff` / `open` / `open-in` / `reveal`. In addition: + +| Method and path | Description | +| --- | --- | +| `POST /api/v1/workspace/fs:search` | Session-less workspace search (the body carries the workspace reference) | +| `GET /api/v1/sessions/{session_id}/fs/{path}:download` | Download a session file (binary, see below) | +| `GET /api/v1/fs:browse` | List host directories (folder picker) | +| `GET /api/v1/fs:home` | The user's home directory and recent workspaces | +| `GET /api/v1/fs:content` | Raw bytes of any host file (gated only by the token — be careful when exposing the port) | +| `POST /api/v1/fs:mkdir` | Create a directory by absolute path | + +### File uploads + +| Method and path | Description | +| --- | --- | +| `POST /api/v1/files` | Multipart upload (`file` field, optional `name` and `expires_in_sec`); returns file metadata | +| `GET /api/v1/files/{file_id}` | Download (binary; errors use real HTTP statuses) | +| `DELETE /api/v1/files/{file_id}` | Delete | + +### Global search and misc + +| Method and path | Description | +| --- | --- | +| `POST /api/v1/search` | Cross-session full-text search; `mode` is `terms` (default) or `literal` (exact substring); `page_token` pagination | +| `GET /api/v1/connections` | List live WebSocket connections | +| `GET /api/v2/sessions` | Next-generation session list, see below | +| `/api/v1/debug/*` | Reflection debug RPC; mounted only with `--debug-endpoints` on loopback, not a stable protocol | + +### `GET /api/v2/sessions` + +A next-generation session query for list views — filtering, sorting, and field groups all travel in query parameters: + +| Parameter | Description | +| --- | --- | +| `workspace.id` | Filter by workspace; repeatable | +| `activity.status` | Filter by activity status: `running` / `approval` / `question` / `failed` / `idle`; repeatable | +| `meta.updated_after` | Only sessions updated after this time (epoch milliseconds) | +| `meta.archived` | `true` / `false` (default) / `all` | +| `sort` | `meta.updated_at_desc` (default) / `meta.updated_at_asc` / `meta.created_at_desc` | +| `include` | Comma-separated extra field groups; currently only `git` (branch and PR info, deduplicated per directory and cached for 60 seconds) | +| `page_size` | 1–100, default 50 | +| `page_token` | Pagination token from the previous page | + +Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git`. The page token binds the first page's query conditions; changing them mid-pagination returns `40922`. + +## WebSocket protocol + +### Connect + +The only endpoint is `ws://:/api/v1/ws`; authentication happens at the upgrade request (see [Authentication](#authentication) above). Once connected, the server immediately sends `server_hello`: + +```json +{ + "type": "server_hello", + "timestamp": "2026-01-01T00:00:00.000Z", + "payload": { + "ws_connection_id": "conn_01JZX4...", + "protocol_version": 2, + "max_event_buffer_size": 1000, + "capabilities": { "event_batching": false, "compression": false } + } +} +``` + +Note that the server never sends heartbeats and never disconnects an idle connection — keepalive and reconnection are the client's job. + +### Control frames + +Clients send JSON frames `{ "type", "id"?, "payload" }`; every request frame gets an acknowledgement `{ "type": "ack", "id", "code", "msg", "payload" }`, where `code` 0 means success. + +| Frame | payload | Description | +| --- | --- | --- | +| `subscribe` | `{ session_ids, cursors?, agent_filter? }` | Subscribe to session events; with `cursors` (per-session `{seq, epoch}`) the server replays missed durable events | +| `unsubscribe` | `{ session_ids }` | Drop session subscriptions | +| `subscribe_v2` | `{ session_id, transcript, transcript_since? }` | Subscribe to transcript streams (the only transcript channel); `transcript` sets per-agent grades | +| `unsubscribe_v2` | `{ session_id, agent_ids? }` | Detach transcript streams; omitting `agent_ids` means the whole session | +| `watch_fs_add` / `watch_fs_remove` | `{ session_id, paths, recursive? }` | Subscribe to / unsubscribe from file-change notifications (`event.fs.changed`) | +| `client_hello` | `{ client_id }` | Handshake frame; the remaining fields are legacy compatibility | + +### Events + +Event frames look like `{ "type", "seq", "epoch"?, "volatile"?, "offset"?, "session_id"?, "timestamp", "payload" }`, where `type` is the event type itself. Two delivery scopes: + +- **Global events**: sent to every established connection, no subscription needed — `session.meta.updated`, `event.session.created`, `event.session.work_changed`, `event.session.status_changed`, `event.workspace.*`, `event.config.*`. +- **Session events**: sent only to connections subscribed to that session, subject to `agent_filter`. Main families: + +| Family | Main events | +| --- | --- | +| Turns | `turn.started`, `turn.ended`, `turn.step.started` / `completed` / `interrupted` / `retrying` | +| Streaming text | `assistant.delta`, `thinking.delta` (carry `offset` for alignment) | +| Tool calls | `tool.call.started`, `tool.call.delta`, `tool.progress`, `tool.result` | +| Interactions | `event.approval.requested` / `resolved`, `event.question.requested` / `answered` / `dismissed` | +| Subagents | `subagent.spawned` / `started` / `suspended` / `completed` / `failed` | +| Background | `task.started` / `terminated`, `shell.started` / `output` / `completed` | +| Misc | `compaction.*`, `skill.activated`, `goal.updated`, `prompt.*`, `error`, `warning` | + +Events also split into durable and volatile: durable events carry a strictly increasing `seq`, are journaled, and can be replayed; volatile events (the `*.delta` family, `tool.progress`, `shell.*`, and similar) are marked `volatile: true` and never replayed. When consuming a volatile text stream, compare `offset` (the cumulative character offset within the turn) against your locally accumulated text: below the local length means a duplicate frame; above means a gap that needs snapshot recovery. + +### Reconnect and recovery + +After reconnecting, pass each session's last applied `{seq, epoch}` in `subscribe`'s `cursors`; the server replays the gap. If you fall more than the buffer (1000 events) behind, or the cursor is no longer valid, you get `resync_required` instead. In that case, call `GET /api/v1/sessions/{session_id}/snapshot` for a full snapshot (with `as_of_seq` and `epoch`), then subscribe again with the fresh cursor. + +### Transcript protocol + +`subscribe_v2`'s `transcript` field sets a per-agent grade: `off` / `turn` / `block` / `delta` (the `"*"` key sets the default grade), with higher grades pushing finer detail. An agent with a non-`off` grade receives two frame types: `transcript.reset` (a baseline snapshot; history pages in over REST) and `transcript.ops` (incremental op batches with a per-agent strictly increasing `seq`). The agent's legacy events are suppressed on that connection and carried by transcript frames instead. After a disconnect, resume with `transcript_since`; when the server's op journal cannot cover the gap (REST catch-up returns `complete: false`), do a full refresh. The REST counterparts are `GET .../transcript` (turn-paged) and `GET .../transcript/ops?since_seq=` (op-batch catch-up). + +## Binary and streaming endpoints + +The following endpoints stream binary bodies instead of a JSON payload. Their HTTP capabilities differ per endpoint: + +| Method and path | Description | Range (206) | ETag / 304 | +| --- | --- | --- | --- | +| `GET /api/v1/files/{file_id}` | Download an uploaded file | Yes | No (sends an `etag` header but ignores `If-None-Match`) | +| `GET /api/v1/sessions/{session_id}/fs/{path}:download` | Download a session workspace file | Yes | Yes | +| `GET /api/v1/fs:content` | Raw bytes of any host file (gated only by the token — be careful when exposing the port) | Yes | Yes | +| `POST /api/v1/sessions/{session_id}/export` | Export the session with diagnostics (zip stream) | No | No | + +Error semantics differ as well: `GET /api/v1/files/{file_id}` answers lookup and storage failures with real 404 / 500 statuses (parameter validation still uses the HTTP 200 envelope), while the other three report every failure through the standard [response envelope](#response-envelope) — clients must keep checking the envelope `code` on those endpoints. + +## Next steps + +- [Local server and API](../guides/server.md) — startup, authentication, and the end-to-end calling flow +- [kimi command](./kimi-command.md#kimi-web) — all `kimi web` command-line options diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index e1f372bab84..5e7bd1abde5 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -16,7 +16,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/logout` | — | Clear credentials for the currently selected account | No | | `/provider` | — | Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-—-interactive-provider-management) | Yes | | `/model` | — | Switch the LLM model used in the current session | Yes | -| `/secondary_model` | — | Configure the secondary model that newly spawned subagents bind to by default (writes the [`[secondary_model]`](../configuration/config-files.md#secondary-model) section and applies to the current session immediately). Requires the `secondary-model` experiment | Yes | +| `/secondary-model` | `/subagent-model` | Pick the default model for subagents (writes `[secondary_model] default_model`; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)). Visible when the subagent model pool experiment is enabled | Yes | | `/settings` | `/config` | Open the settings panel inside the TUI | Yes | | `/experiments` | `/experimental` | Open the experimental feature panel | Yes | | `/permission` | — | Select a permission mode | Yes | diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 8b412b53667..cf16c9200f9 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -89,9 +89,9 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | -**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (`"secondary"` for the secondary model configured via `[secondary_model] model`, or `"primary"` for the main model; ignored when resuming; available when the secondary-model experiment is enabled). An explicit `model` overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (available only when the [subagent model pool](../configuration/config-files.md#subagent-model-pool) experiment is enabled and a pool is configured — a `[secondary_model.models]` table or a lone `default_model`: a pool alias, or `"primary"` for the model the caller itself is running; ignored when resuming). Without it, the subagent binds the pool's `default_model`; without a configured pool, subagents always inherit the caller's model. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. -**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available when the secondary-model experiment is enabled) to run item-spawned subagents on the secondary model configured via `[secondary_model] model` (`"secondary"`) or the main model (`"primary"`). This explicit choice overrides the selected [agent profile's `model_preference`](../customization/agents.md#agent-file-format); without either, the configured secondary model is the default, or the subagent inherits the caller's model when no secondary model is configured. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. +**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available only when the [subagent model pool](../configuration/config-files.md#subagent-model-pool) experiment is enabled and a pool is configured — a `[secondary_model.models]` table or a lone `default_model`) to run item-spawned subagents on a pool alias or on the caller's own model (`"primary"`). Without it, item-spawned subagents bind the pool's `default_model`; without a configured pool, they inherit the caller's model. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index abdd0a2d04d..4680f1c9e5c 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,52 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.36.0 (2026-08-13) + +### Features + +- Upgrade the experimental subagent model setting to a model pool: the `[secondary_model]` section can now hold a set of candidate models with descriptions, and the main agent picks from them per spawn based on the task. + + Set `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` (or the master flag `KIMI_CODE_EXPERIMENTAL_FLAG=1`) before starting Kimi to enable it. + + Recommended setups: + + - Minimal: run `/secondary-model` in the TUI, or write a single `default_model` line in `config.toml`, to make every subagent run the same model by default; add `force = true` to pin that choice so the main agent cannot override it. + - Declare a named pool with a one-line scenario description for each alias — the descriptions are what the main agent sees when choosing: + + ```toml + [secondary_model] + default_model = "kimi-code/kimi-for-coding-highspeed" + [secondary_model.models] + "kimi-code/kimi-for-coding-highspeed" = "Fast and cheap — good for daily refactoring, code explanation, and small edits." + "kimi-code/k3" = "Strong at complex reasoning and deep debugging — pick it for hard problems." + ``` + + See the [subagent model pool docs](https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#subagent-model-pool) for details. +- Add an experimental fullscreen TUI mode. Set the `KIMI_CODE_TUI_FULL_SCREEN=1` environment variable to enable it. +- Support rendering LaTeX math formulas (`$…$` / `$$…$$`) in TUI messages as Unicode formulas. + +### Bug Fixes + +- Show project MCP launch targets in the workspace trust prompt, default to declining trust, and resolve `fd` and `stty` binaries to absolute paths so untrusted workspaces cannot plant bare-name executables before confirmation. +- Fix sessions failing with a provider 400 error on every follow-up request after a turn is interrupted while the model is still thinking, on strict OpenAI-compatible providers (e.g. DeepSeek). +- Fix Ctrl+C being ignored during automatic retries of failed API requests. +- Fix several known issues and make various refinements. See the [changelog on GitHub](https://github.com/MoonshotAI/kimi-code/blob/main/apps/kimi-code/CHANGELOG.md) for more technical entries. + +## 0.35.0 (2026-08-12) + +### Features + +- Add the Modern Web Guidance plugin to the bundled plugin marketplace. Run `/plugins` and select Modern Web Guidance to install it. +- Show the live work progress of background subagents in the `/tasks` panel. + +### Bug Fixes + +- Fix coder subagents spawning further subagents by default. +- Fix the token counts reported after compaction reading far below the real context size; they now match the numbers shown while the session runs. +- Fix two binary-planting risks on Windows. +- Fix several known issues and make various refinements. See the [changelog on GitHub](https://github.com/MoonshotAI/kimi-code/blob/main/apps/kimi-code/CHANGELOG.md) for more technical entries. + ## 0.34.0 (2026-08-06) ### Features diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index b1e3e43a1ce..4f55a51b9bd 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -196,34 +196,71 @@ display_name = "Kimi for Coding (custom)" ## `secondary_model` -次主力模型是主模型之外的第二个模型配置——通常是一个更便宜的模型,供不需要主模型能力的功能绑定使用。它目前的消费者是子 Agent 派生:设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;未设置时,子 Agent 继承主 Agent 的模型。 +subagent 默认继承 main agent 正在运行的模型。`[secondary_model]` 节把这件事变成可配置的:为 subagent 准备一批候选模型(模型池)并指定默认绑定——通常是一个更便宜的模型,供不需要主模型能力的子任务使用。 -这是默认绑定而非强制。实验功能启用后,`Agent` / `AgentSwarm` 工具会获得 `model` 参数(仅接受 `"secondary"` / `"primary"` 两个符号值),工具描述中也会列出可选模型并标注默认值。派生时按以下顺序解析子 Agent 的模型:工具调用显式传入的 `model` → 子 Agent profile 的 [`model_preference`](../customization/agents.md#agent-文件格式) → 已配置的次主力模型(默认)。其中 `"primary"` 指主 Agent 当前正在运行的模型,不一定是 `default_model`——例如会话中途用 `/model` 切换过模型。 +### subagent 模型池 -由于是否覆盖默认值由主 Agent 自行决定(工具描述仅建议常规任务用 `"secondary"`、困难或质量敏感的任务用 `"primary"`,不构成强制),用户没有单次派生级别的直接开关。想让某个子 Agent 使用主模型,可以在提示词中要求主 Agent 传入 `model: "primary"`,或在对应 profile 中设置 `model_preference: "primary"`。 +该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。实验功能关闭时,模型池配置不生效:subagent 继承调用方模型,会话启动也会跳过池校验。 -该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。 +只想让所有 subagent 默认换用一个模型时不需要 models 表——一行 `default_model` 就是只含一个条目的模型池: -在交互式 TUI 中,可以使用 [`/secondary_model`](../reference/slash-commands.md) 命令打开模型选择器来设置该配置:选择后会写入本小节配置,并在当前会话立即生效——之后派生的子 Agent 会直接绑定新的次主力模型。 +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +``` + +在交互式 TUI 中,也可以用 [`/secondary-model`](../reference/slash-commands.md) 命令(别名 `/subagent-model`)打开模型选择器来设置:选择后写入 `default_model`(已有 models 表而所选别名不在其中时,会一并补一条空描述条目),之后派生的 subagent 立即按新默认值绑定,无需重启会话。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `model` | `string` | — | [`[models]`](#models) 中已配置条目的别名,如 `kimi-code/kimi-k2.5`(不限 kimi 模型,可用任意供应商) | -| `default_effort` | `string` | — | 子 Agent 绑定次主力模型时使用的 thinking effort。未设置时按"全局 `[thinking]` 配置 → 模型默认 effort"的链路解析,不再继承主 Agent 的 effort。与主模型的 thinking effort 语义一致:严格校验 effort 的模型(如 kimi 模型)在不支持该取值时回退到模型默认 effort,其他供应商的模型按原样发送给后端 | -| 其他字段 | — | — | 接受 [`[models."".overrides]`](#models) 的全部字段(`max_context_size`、`max_output_size`、`support_efforts` 等),作为仅对子 Agent 生效的模型补丁 | +| `default_model` | `string` | — | subagent 默认模型。配置 `[secondary_model.models]` 时必填,且必须是其中的 key;单独写下它(不写 models 表)则等价于只含它一个条目的模型池 | +| `models` | `table` | — | subagent 模型池。key 是 [`[models]`](#models) 中已配置条目的别名,value 是 main agent 挑选 subagent 模型时看到的描述(中英文均可;空字符串表示只列出别名、不给提示) | +| `force` | `boolean` | `false` | 把所有 subagent 固定到 `default_model`:不再提供 `model` 参数,main agent 无法改选其他模型或 `"primary"`。必须配置 `default_model`,且不能与 `[secondary_model.models]` 同时使用 | + +配置模型池(显式的 `[secondary_model.models]` 表,或仅一行 `default_model` 形成的隐式单条目池)即启用模型选择:`Agent` / `AgentSwarm` 工具会获得 `model` 参数,工具描述中会列出模型池(默认模型标注 `[default]`),main agent 可按次派生选择模型(除非设置了 `force`,见下文)。模型池只引用已配置的 [`[models]`](#models) 条目——下面的 `kimi-code/*` 别名由 `/login` 自动提供——并附上挑选提示: + +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +[secondary_model.models] +"kimi-code/k3" = "难题选它。擅长复杂推理、算法设计、深度调试、数学和系统性难题。" +"kimi-code/kimi-for-coding-highspeed" = "又快又便宜。适合日常重构、代码解释、小改动、总结和批量简单任务。" +"kimi-code/kimi-for-coding" = "均衡的编码主力。适合大多数功能开发和代码修改任务。" +``` + +派生时按以下顺序解析 subagent 的模型:工具调用显式传入的 `model` → `default_model`。`model` 参数接受池中任意别名,或 `"primary"` ——调用方自己正在运行的模型,始终合法,即使它不在池中。`default_model` 与 `[secondary_model.models]` 都未配置时,该参数不会出现,subagent 继承调用方模型。绑定池中别名时不携带显式 Thinking 档位——subagent 按 "全局 `[thinking]` 配置 → 所绑定模型的默认 effort" 自然解析,不继承调用方的档位;`"primary"` 则连模型带档位一起继承调用方。 + +要彻底收回 main agent 的选择权——让所有 subagent 固定跑在同一个模型上——加上 `force = true`: -`model` 之外的字段构成补丁:存在补丁字段时,运行时会在内存中合成一个派生模型条目(被指向条目的拷贝,补丁并入其 overrides 且补丁优先),子 Agent 实际绑定该派生条目;没有补丁字段时,子 Agent 直接绑定 `model` 指向的条目。派生条目只存在于内存中(不写回 `config.toml`),也不会出现在模型选择列表里。 +```toml +[secondary_model] +default_model = "kimi-code/kimi-for-coding-highspeed" +force = true +``` + +设置 `force` 后不再提供 `model` 参数(与完全未配置时一样),每次派生都绑定 `default_model`;显式传入 `model`(包括 `"primary"`)会报错。`force` 必须搭配 `default_model`,且不能与 `[secondary_model.models]` 表同时使用——表的意义在于提供选择,而 force 取消了选择。 + +利用自然解析会落到所绑定模型的默认 effort 这一点,可以给池中不同条目配不同的 Thinking 档位:为同一个底层模型再注册一个 `[models]` 条目作为「变体」,用 [`[models."".overrides]`](#模型覆盖项) 只覆盖 `default_effort`,再把两个别名都放进模型池——main agent 挑选别名时便同时选定了档位: ```toml +# "kimi-code/kimi-for-coding-highspeed" 由 /login 提供;这里为同一模型注册一个高档位变体 +[models.kimi-for-coding-highspeed-deep] +provider = "managed:kimi-code" +model = "kimi-for-coding-highspeed" + +[models.kimi-for-coding-highspeed-deep.overrides] +default_effort = "high" + [secondary_model] -model = "kimi-code/kimi-k2.5" -default_effort = "low" -max_output_size = 8192 +default_model = "kimi-code/kimi-for-coding-highspeed" +[secondary_model.models] +"kimi-code/kimi-for-coding-highspeed" = "又快又便宜。适合日常重构、代码解释、小改动、总结和批量简单任务。" +kimi-for-coding-highspeed-deep = "同一模型的高 Thinking 档位。适合较难的子任务。" ``` -`model` / `default_effort` 可被环境变量 `KIMI_SECONDARY_MODEL` / `KIMI_SECONDARY_EFFORT` 覆盖,优先级均高于配置文件。 +注意 `default_effort` 是模型级默认值:一旦设置了全局 `[thinking].effort`,它对 main agent 和 subagent 都优先生效,变体的默认档位只在全局未设置时起作用。取值与回落规则同 [`[models]` 条目的 `default_effort`](#models)。 -实验功能启用后,会话启动时会校验该配置:`model` 无法解析,或 `default_effort` 不在(应用补丁后的)模型 effort 列表中时,会在启动时显示警告(并通过会话警告 API 返回)。该检查仅为提示——配置有误的次主力模型仍会在派生子 Agent 时失败,派生错误中同样附带配置来源提示。 +配置错误一律直接报错,不做静默回退:`default_model` 缺失、不是池中 key,或池中 key 无法解析到已配置的 `[models]` 条目时,会话的创建、恢复(resume)与 fork 都会在启动时直接失败;`force` 未搭配 `default_model` 或与 `[secondary_model.models]` 表同用时亦然。别名 `primary` 是保留字——它始终绑定调用方自己的模型——不能作为池中 key。工具调用传入的 `model` 既不是池中别名也不是 `"primary"` 时,本次派生报错并列出可选值。 ## `thinking` @@ -279,19 +316,22 @@ max_output_size = 8192 | `kill_grace_period_ms` | `integer` | `5000` | 会话关闭、手动停止或任务超时请求正常终止后,等待任务自行结束的宽限时间(毫秒)。超过该时间仍在运行时,Kimi Code 会尝试强制停止该任务 | | `bash_auto_background_on_timeout` | `boolean` | `true` | 前台 `Bash` 命令触及超时时间时,将其转为后台任务而不是直接终止:命令完成时 agent 会收到通知,转入后台的命令受 `bash_task_timeout_s` 默认后台超时约束。设为 `false` 则恢复超时即终止的行为 | | `bash_task_timeout_s` | `integer` | `600` | 后台 `Bash` 任务在调用未传 `timeout` 时的默认超时(秒);前台命令超时转后台后也按此值重新计时。`0` 表示无超时——任务一直运行到自行结束或被模型手动停止。显式传入的 `timeout` 不受影响。在 print 模式(`kimi -p`)下未显式设置时默认为 `0` | -| `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"steer"` | 仅 print 模式(`kimi -p`)生效,决定主 agent 的 turn 结束后如何处理未返回的后台任务:`"exit"` 立即退出;`"drain"` 退出前等待所有后台任务进入终态(结果不回馈给主 agent);`"steer"` 不退出,让后台任务完成时像后台子代理一样以合成 user 消息 steer 主 agent 进入新 turn,直到某 turn 结束时无未决后台任务或触及上限。设置后优先级高于 `keep_alive_on_exit` 的 print 回退 | +| `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"steer"` | 仅 print 模式(`kimi -p`)生效,决定 main agent 的 turn 结束后如何处理未返回的后台任务:`"exit"` 立即退出;`"drain"` 退出前等待所有后台任务进入终态(结果不回馈给 main agent);`"steer"` 不退出,让后台任务完成时像后台 subagent 一样以合成 user 消息 steer main agent 进入新 turn,直到某 turn 结束时无未决后台任务或触及上限。设置后优先级高于 `keep_alive_on_exit` 的 print 回退 | | `print_wait_ceiling_s` | `integer` | `2147483` | print 模式(`kimi -p`)下,`print_background_mode` 为 `"drain"` 或 `"steer"` 时,等待/steer 循环的墙钟上限(秒;默认约 24.8 天,近似不设限)。在非 print 模式或 `"exit"` 时无效 | | `print_max_turns` | `integer` | `100000` | print 模式(`kimi -p`)且 `print_background_mode = "steer"` 时,允许由后台任务完成触发的新 turn 的最大数量,防止 steer 循环失控(默认值近似不设限) | `keep_alive_on_exit` 可被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖,`max_running_tasks` 可被 `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` 覆盖,优先级均高于配置文件。 -在 print 模式(`kimi -p ""`)下,只要还有未决的后台任务,Kimi Code 在主 agent 的 turn 结束后不会退出:每个任务完成都会以合成 user 消息回馈给主 agent,steer 出新的 turn(默认 `print_background_mode = "steer"`),直到某 turn 结束时没有任何未决任务才退出。该循环受 `print_wait_ceiling_s` 与 `print_max_turns` 约束,默认值都近似不设限。print 模式下后台工作也不会被墙钟超时杀掉:后台 `Bash` 任务默认无超时(`bash_task_timeout_s = 0`),子代理默认无超时(`[subagent] timeout_ms = 0`),只有模型自己能停止任务。将 `print_background_mode` 设为 `"drain"` 可等待任务结束但不回馈结果,设为 `"exit"` 则在主 agent 结束后立即退出。 +在 print 模式(`kimi -p ""`)下,只要还有未决的后台任务,Kimi Code 在 main agent 的 turn 结束后不会退出:每个任务完成都会以合成 user 消息回馈给 main agent,steer 出新的 turn(默认 `print_background_mode = "steer"`),直到某 turn 结束时没有任何未决任务才退出。该循环受 `print_wait_ceiling_s` 与 `print_max_turns` 约束,默认值都近似不设限。print 模式下后台工作也不会被墙钟超时杀掉:后台 `Bash` 任务默认无超时(`bash_task_timeout_s = 0`),subagent 默认无超时(`[subagent] timeout_ms = 0`),只有模型自己能停止任务。将 `print_background_mode` 设为 `"drain"` 可等待任务结束但不回馈结果,设为 `"exit"` 则在 main agent 结束后立即退出。 ## `subagent` +`subagent` 控制派生 subagent(`Agent` / `AgentSwarm`)的运行方式。 + | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `timeout_ms` | `integer` | `7200000`(2 小时) | 单个子代理(`Agent` / `AgentSwarm`)允许运行的最长时间(毫秒)。超时后子代理以 `timed_out` 收尾。`0` 表示无超时——子代理一直运行到自行结束或被模型手动停止。该值是后台任务管理器对每个子代理任务的 per-task timeout,因此对前台与后台子代理同时生效。在 print 模式(`kimi -p`)下未显式设置时默认为 `0`。注意:超过 `2147483647`(约 24.8 天)的值会被运行时钳到约 24.8 天 | +| `timeout_ms` | `integer` | `7200000`(2 小时) | 单个 subagent(`Agent` / `AgentSwarm`)允许运行的最长时间(毫秒)。超时后 subagent 以 `timed_out` 收尾。`0` 表示无超时——subagent 一直运行到自行结束或被模型手动停止。该值是后台任务管理器对每个 subagent 任务的 per-task timeout,因此对前台与后台 subagent 同时生效。在 print 模式(`kimi -p`)下未显式设置时默认为 `0`。注意:超过 `2147483647`(约 24.8 天)的值会被运行时钳到约 24.8 天 | + `timeout_ms` 可被环境变量 `KIMI_SUBAGENT_TIMEOUT_MS` 覆盖,优先级高于配置文件。 ## `mcp` @@ -459,6 +499,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light`,或[自定义主题](../customization/themes.md)的名字 | +| `render_latex` | `boolean` | `true` | 将 Markdown 消息中的 LaTeX 公式(`$…$`、`$$…$$`)渲染为 Unicode 文本;`false` 则保留原始源码 | | `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 | | `cache_expiry_hint` | `boolean` | `true` | resume 长时间未活动的会话、或长时间空闲后发送消息时,若上下文缓存可能已过期则弹出提醒,可选择先压缩或新建会话(仅 v2 引擎) | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | @@ -471,6 +512,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod ```toml # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 +render_latex = true # false 表示消息中的 LaTeX 公式保留原始源码 disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底 cache_expiry_hint = true # false 表示关闭 resume / 空闲提交时的"缓存已过期"提醒弹窗 diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index 5ab5aaa0277..302198278f8 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -76,9 +76,9 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) - **`state.json`**:会话标题、`lastPrompt`、创建/更新时间、`forkedFrom` 等元数据。 - **`upcoming-goals.json`**:由 `/goal next ` 创建的 TUI 专属队列。它不属于 Agent 对话;只有当前目标完成并提升后续目标后,才会进入 Agent 对话。 -- **`agents/main/wire.jsonl`**:主 Agent 的完整通信记录,用于会话恢复和回放。 +- **`agents/main/wire.jsonl`**:main agent 的完整通信记录,用于会话恢复和回放。 - **`agents/main/plans/`**:Plan 模式下写入的计划文件,按计划 id 命名(`.md`)。 -- **`agents/agent-0/` 等**:子 Agent 实例目录,各自含 `wire.jsonl`。 +- **`agents/agent-0/` 等**:subagent 实例目录,各自含 `wire.jsonl`。 - **`logs/kimi-code.log`**:该会话的诊断日志,只有发生诊断事件时才存在。 - **`tasks/`**:后台任务持久化——`tasks/.json` 保存状态/pid/退出码,`tasks//output.log` 保存输出。 - **`cron/`**:定时任务持久化,用 `kimi --session` 恢复会话时重新加载到调度器。详见[定时任务](../reference/tools.md#定时任务)。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 8d44b78734e..a31507fe25d 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -121,19 +121,19 @@ kimi | 环境变量 | 用途 | 合法值 | | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1`、`true`、`yes`、`y`(不区分大小写) | +| `KIMI_CODE_PASSWORD` | 为 `kimi web` 本地服务设置并列鉴权密码,与 bearer token 同时有效;把服务绑定到非本机地址时建议设置,见[本地服务与 API](../guides/server.md#鉴权) | 任意非空字符串;未设置时仅 token 有效 | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` | 同时运行的后台任务数上限,优先级高于 `config.toml` 的 `[background] max_running_tasks`(不设置表示无上限) | 正整数;非法值被忽略 | | `KIMI_IMAGE_MAX_EDGE_PX` | 图片压缩的最长边上限(像素),优先级高于 `config.toml` 的 `[image] max_edge_px`(默认 `2000`) | 正整数;非法值被忽略 | | `KIMI_IMAGE_READ_BYTE_BUDGET` | 模型自行读图(`ReadMediaFile` 默认读取)的单图字节预算,优先级高于 `config.toml` 的 `[image] read_byte_budget`(默认 `262144`,即 256 KB) | 正整数;非法值被忽略 | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 | -| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | -| `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | +| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的 subagent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | +| `KIMI_SUBAGENT_TIMEOUT_MS` | 单个 subagent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | | `KIMI_CODE_IDENTITY_NAME` | Agent 在系统提示词中的自称,优先级高于 `config.toml` 的 `[identity] name`,且不会被写回配置文件 | 任意非空字符串;空值视为未设置 | | `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | -| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的次主力模型功能;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | -| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 [`[secondary_model] model`](./config-files.md#secondary-model)。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | `[models]` 中已配置条目的别名,如 `kimi-code/kimi-k2.5`;空白值被忽略 | -| `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效 | effort 取值,如 `low`;空白值被忽略 | +| `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | +| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的[subagent 模型池](./config-files.md#subagent-模型池);master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 97d98de3e22..d25ba177c67 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -1,47 +1,47 @@ -# Agent 与子 Agent +# Agent 与 subagent -Kimi Code CLI 中的每次会话都由一个**主 Agent** 驱动。主 Agent 理解用户意图、规划步骤、调用工具,并在需要时向外派发**子 Agent** 处理更聚焦的子任务——例如探索一个陌生代码库、并行审阅多处实现、或在不触碰主上下文的情况下规划一次大型重构。 +Kimi Code CLI 中的每次会话都由一个**main agent** 驱动。main agent 理解用户意图、规划步骤、调用工具,并在需要时向外派发**subagent** 处理更聚焦的子任务——例如探索一个陌生代码库、并行审阅多处实现、或在不触碰主上下文的情况下规划一次大型重构。 -子 Agent 接受主 Agent 给出的任务描述,在自己的独立上下文里工作,最后把结论返回。它不会与用户直接对话,中间的思考和工具调用记录也不会混入主 Agent 的历史。 +subagent 接受 main agent 给出的任务描述,在自己的独立上下文里工作,最后把结论返回。它不会与用户直接对话,中间的思考和工具调用记录也不会混入 main agent 的历史。 -## 内置子 Agent +## 内置 subagent -Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形态: +Kimi Code CLI 内置三种 subagent,开箱即用,分别面向不同任务形态: -- **`coder`**:默认子 Agent,通用软件工程助手,可以读写文件、执行命令、搜索代码并落地具体改动。 +- **`coder`**:默认 subagent,通用软件工程助手,可以读写文件、执行命令、搜索代码并落地具体改动。 - **`explore`**:代码库探索专用,只做只读操作,不修改任何文件。适合在不改动文件的前提下快速搜索、阅读和总结仓库。 - **`plan`**:实现规划与架构设计专用,连 Shell 命令都不提供,专注于"想清楚怎么做"而不是"动手做"。 -`coder` 子 Agent 与主 Agent 共享大部分工具集:可以在后台执行 Shell 命令、维护待办列表、进入 Plan 模式、调用 Agent Skills,也可以在任务自然拆解时继续派发自己的嵌套子 Agent。如果它结束自己的轮次时仍有后台任务在运行,那么只有在这些后台任务全部落定后,这次运行才会回报完成——主 Agent 拿到结果时,背后的工作也已经真正完成。 +`coder` subagent 与 main agent 共享大部分工具集:可以在后台执行 Shell 命令、维护待办列表、进入 Plan 模式、调用 Agent Skills,也可以在任务自然拆解时继续派发自己的嵌套 subagent。如果它结束自己的轮次时仍有后台任务在运行,那么只有在这些后台任务全部落定后,这次运行才会回报完成——main agent 拿到结果时,背后的工作也已经真正完成。 ## 调用方式 -子 Agent 由主 Agent 自动调度——根据任务复杂度、上下文消耗和子任务的独立性,在适当时机派发,无需用户手动指定。 +subagent 由 main agent 自动调度——根据任务复杂度、上下文消耗和子任务的独立性,在适当时机派发,无需用户手动指定。 -每次派发都会在终端以审批请求的形式呈现(除非命中 allow 规则或处于 YOLO 模式),方便你审视任务描述。你也可以在对话中直接指示主 Agent 使用特定子 Agent,例如"先用 explore 把相关文件梳理一遍再动手"。 +每次派发都会在终端以审批请求的形式呈现(除非命中 allow 规则或处于 YOLO 模式),方便你审视任务描述。你也可以在对话中直接指示 main agent 使用特定 subagent,例如"先用 explore 把相关文件梳理一遍再动手"。 -子 Agent 支持在后台运行:完成后结果自动回到主 Agent,无需手动轮询。也可以唤回已有的子 Agent 实例继续推进同一任务。 +subagent 支持在后台运行:完成后结果自动回到 main agent,无需手动轮询。也可以唤回已有的 subagent 实例继续推进同一任务。 ## 上下文隔离与资源开销 -每个子 Agent 拥有完全独立的上下文窗口,只能看到主 Agent 显式传入的任务描述,看不到主 Agent 的对话历史。子 Agent 自己的中间思考和工具调用记录不会回流,只有最终结果会出现在主 Agent 的上下文里。 +每个 subagent 拥有完全独立的上下文窗口,只能看到 main agent 显式传入的任务描述,看不到 main agent 的对话历史。subagent 自己的中间思考和工具调用记录不会回流,只有最终结果会出现在 main agent 的上下文里。 这种隔离带来两个好处: -- **主 Agent 上下文保持精炼**,长会话中不会被大量探索性日志撑满。 -- **多个子 Agent 可以并行运行**,互不干扰。 +- **main agent 上下文保持精炼**,长会话中不会被大量探索性日志撑满。 +- **多个 subagent 可以并行运行**,互不干扰。 -需要注意的是,每个子 Agent 都会独立消耗模型 token。简单任务没有必要派发子 Agent,主 Agent 直接处理更经济。 +需要注意的是,每个 subagent 都会独立消耗模型 token。简单任务没有必要派发 subagent,main agent 直接处理更经济。 ## 权限继承 -子 Agent 的权限规则继承自主 Agent:主 Agent 通过 `/permission` 或在审批中接受的"始终允许"规则,会自动覆盖到它派发出的所有子 Agent,子 Agent 不需要重新审批同类工具调用。`Agent` 工具本身默认放行,因此主 Agent 可以在不打断用户的前提下完成多次委派。 +subagent 的权限规则继承自 main agent:main agent 通过 `/permission` 或在审批中接受的"始终允许"规则,会自动覆盖到它派发出的所有 subagent,subagent 不需要重新审批同类工具调用。`Agent` 工具本身默认放行,因此 main agent 可以在不打断用户的前提下完成多次委派。 -如果需要某类工具在子 Agent 中始终不可用,应收紧主 Agent 的权限规则。 +如果需要某类工具在 subagent 中始终不可用,应收紧 main agent 的权限规则。 ## 自定义 Agent -除了三个内置子 Agent,你还可以用 Markdown 文件定义自己的 Agent。每个文件描述一个 Agent:文件顶部的 Frontmatter(YAML 元数据)声明名称、描述和工具权限,文件正文是它的系统提示词。自定义 Agent 可以作为子 Agent 被委派 —— 主 Agent 会自动发现它们,与内置子 Agent 并列 —— 也可以在启动时选为主 Agent。 +除了三个内置 subagent,你还可以用 Markdown 文件定义自己的 Agent。每个文件描述一个 Agent:文件顶部的 Frontmatter(YAML 元数据)声明名称、描述和工具权限,文件正文是它的系统提示词。自定义 Agent 可以作为 subagent 被委派 —— main agent 会自动发现它们,与内置 subagent 并列 —— 也可以在启动时选为 main agent。 ### Agent 目录 @@ -65,10 +65,10 @@ extra_agent_dirs = ["~/team-agents", ".agents/team-agents"] **Plugin 级**:已启用 plugin 在其 manifest 的 `agents` 字段中声明的目录(省略时自动采用 plugin 根下的 `agents/` 目录),见[插件 Agent](./plugins.md#插件-agent)。Plugin Agent 优先级仅高于内置 Agent。 -**内置 Agent** 随 CLI 分发,优先级最低。目录中发现的文件不会仅凭同名覆盖内置 Agent;如确需替换,必须在 Frontmatter 中声明 `override: true`。通过 `--agent-file` 加载的文件视为显式启动意图,可以覆盖同名内置 Agent,优先级高于所有目录作用域,且仅对本次启动生效。另外,`$KIMI_CODE_HOME/SYSTEM.md` 可永久覆盖默认主 Agent 的系统提示词(它不参与 Agent 文件发现),其优先级交互见下文 SYSTEM.md 小节。 +**内置 Agent** 随 CLI 分发,优先级最低。目录中发现的文件不会仅凭同名覆盖内置 Agent;如确需替换,必须在 Frontmatter 中声明 `override: true`。通过 `--agent-file` 加载的文件视为显式启动意图,可以覆盖同名内置 Agent,优先级高于所有目录作用域,且仅对本次启动生效。另外,`$KIMI_CODE_HOME/SYSTEM.md` 可永久覆盖默认 main agent 的系统提示词(它不参与 Agent 文件发现),其优先级交互见下文 SYSTEM.md 小节。 ::: warning 信任模型 -Agent 文件属于提示词配置,而项目级文件来自仓库本身 —— 包括你刚刚 clone、尚不可信的仓库。项目作用域的文件可以完全接管内置 Agent:命名为 `agent.md` 并声明 `override: true` 会替换**默认主 Agent 的整个系统提示词**,`coder.md` 加 `override: true` 则会替换默认子 Agent 类型。与 `AGENTS.md` 内容(作为参考资料注入提示词)不同,override 文件**就是**系统提示词本身,且不写 `tools` 的文件保留全部工具。在不熟悉的仓库中运行 Kimi Code 之前,请以对待脚本同样的谨慎检查其中的 `.kimi-code/agents/` 与 `.agents/agents/` 目录。 +Agent 文件属于提示词配置,而项目级文件来自仓库本身 —— 包括你刚刚 clone、尚不可信的仓库。项目作用域的文件可以完全接管内置 Agent:命名为 `agent.md` 并声明 `override: true` 会替换**默认 main agent 的整个系统提示词**,`coder.md` 加 `override: true` 则会替换默认 subagent 类型。与 `AGENTS.md` 内容(作为参考资料注入提示词)不同,override 文件**就是**系统提示词本身,且不写 `tools` 的文件保留全部工具。在不熟悉的仓库中运行 Kimi Code 之前,请以对待脚本同样的谨慎检查其中的 `.kimi-code/agents/` 与 `.agents/agents/` 目录。 ::: ### Agent 文件格式 @@ -81,7 +81,6 @@ name: reviewer description: 严格的代码审查 Agent,按严重度分级报告问题 whenToUse: 代码评审与 PR 检查 override: false -model_preference: primary tools: - Read - Grep @@ -97,13 +96,12 @@ disallowedTools: | 字段 | 必填 | 说明 | | --- | --- | --- | | `name` | 否 | kebab-case 唯一标识。缺省时取文件名(去掉扩展名,如 `review.md` → `review`);解析后名字缺失或不是 kebab-case 的文件会被跳过并告警 | -| `description` | 是 | Agent 的用途。主 Agent 挑选子 Agent 时会看到,请围绕委派决策来写 | +| `description` | 是 | Agent 的用途。main agent 挑选 subagent 时会看到,请围绕委派决策来写 | | `whenToUse` | 否 | 补充说明何时应使用该 Agent | | `override` | 否 | 是否允许覆盖同名内置 Agent,默认 `false`。`--agent-file` 属于显式启动意图,无需设置此字段 | -| `model_preference` | 否 | `Agent` 或 `AgentSwarm` 启动该 profile 时的符号默认值:`primary` 选择调用方当前运行的模型,`secondary` 选择 [`[secondary_model] model`](../configuration/config-files.md#secondary-model)。工具调用显式传入的 `model`(同样只接受 `"primary"` / `"secondary"` 两个符号值)优先于该字段;两者均未设置时,已配置的次主力模型仍为默认值。未配置次主力模型时,子 Agent 继承调用方模型 | | `tools` | 否 | 工具名允许列表,如 `Read`、`Bash`;MCP 工具用 glob 匹配,如 `mcp__github__*`。支持 YAML 列表或逗号分隔字符串(`tools: Read, Grep`)两种写法。缺省表示允许全部工具;单独的 `*` 同样表示允许全部工具;空列表(`tools: []`)表示禁用全部工具 | | `disallowedTools` | 否 | 禁止列表,写法与匹配规则相同,在 `tools` 之后应用 | -| `subagents` | 否 | 允许委派的子 Agent 名称列表,写法与 `tools` 相同(YAML 列表或逗号分隔字符串)。缺省表示可委派所有类型;单独的 `*` 同样表示全部 | +| `subagents` | 否 | 允许委派的 subagent 名称列表,写法与 `tools` 相同(YAML 列表或逗号分隔字符串)。缺省表示可委派所有类型;单独的 `*` 同样表示全部 | 内置工具与用户工具按名称精确匹配(区分大小写);以 `mcp__` 开头的条目按 glob 匹配 MCP 工具。有三种写法永远匹配不到任何工具,在 profile 生效时会给出警告:`mcp__` 模式之外使用通配符(`disallowedTools` 里单独的 `*` 什么也禁不掉);不是完整 `mcp__<服务器>__<工具>` 形式的 `mcp__` 字面量(`mcp__github` 匹配不到任何工具 —— 匹配整个服务器要用 `mcp__github__*`);以及任何已注册或内置工具都没有的名字(通常是笔误,如把 `Read` 写成 `read`)。 @@ -111,21 +109,19 @@ disallowedTools: 未知字段会被忽略,新版本写的文件在旧版本上仍可读取。其他 Agent 工具的字段(如 Claude Code 的 `model`、OpenCode 的 `mode`)同样会被忽略;加上 `tools` 的逗号分隔写法和 `name` 缺省回退到文件名,Claude Code 与 OpenCode 风格的 Agent 文件一般可直接加载 —— 只含 `description` 和正文的最小文件可跨工具通用。 -`model_preference` 仅在次主力模型实验功能启用时对新启动的子 Agent 生效——设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`,或 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。该字段不用于填写具体模型 alias,已恢复的子 Agent 也会保持原模型。主 Agent 会在 profile 描述中看到这项偏好,因此仍可在某项任务需要不同选择时显式传入 `model`。 - 目录中发现的非法文件会被跳过并告警,不影响其他文件。通过 `--agent-file` 显式传入的文件必须合法 —— 否则 CLI 会报错并退出。 ::: warning 注意 -`tools` 与 `disallowedTools` 不仅决定模型能"看到"哪些工具,还会在执行前再次强制检查。`subagents` 同样双重生效:`Agent` 工具的类型列表只包含允许委派的子 Agent,`Agent` 与 `AgentSwarm` 在实际派发前都会强制校验;唤回已有子 Agent 不受此限制。权限规则仍是独立的控制层,用于决定哪些操作需要审批。 +`tools` 与 `disallowedTools` 不仅决定模型能"看到"哪些工具,还会在执行前再次强制检查。`subagents` 同样双重生效:`Agent` 工具的类型列表只包含允许委派的 subagent,`Agent` 与 `AgentSwarm` 在实际派发前都会强制校验;唤回已有 subagent 不受此限制。权限规则仍是独立的控制层,用于决定哪些操作需要审批。 ::: -作为子 Agent 委派的自定义 Agent 不会携带内置子 Agent 的角色框架("你的最后一条消息就是完整交付")。如果编写的 Agent 用于委派,请在正文中说明:其最后一条消息应当是交付给调用方的完整、自包含的结果。 +作为 subagent 委派的自定义 Agent 不会携带内置 subagent 的角色框架("你的最后一条消息就是完整交付")。如果编写的 Agent 用于委派,请在正文中说明:其最后一条消息应当是交付给调用方的完整、自包含的结果。 -### 选择主 Agent +### 选择 main agent 两个 CLI flag 用于选择驱动新会话的 Agent,在 print 模式(`kimi -p`)和交互式 TUI 中均可使用: -- **`--agent `**:以指定 Agent 作为主 Agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 +- **`--agent `**:以指定 Agent 作为 main agent 启动会话。名称可以指向内置 Agent 或任何已发现的文件;名称不存在时会报错,并列出可用的 Agent。 - **`--agent-file `**:以最高优先级加载一个 Agent 文件(仅本次启动)并以其启动。该 flag 只接受一个文件:不可重复传入,也不能与 `--agent` 同时使用。 两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合。Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent,因此恢复时不需要(也不允许)携带这些 flag。 @@ -139,11 +135,11 @@ kimi -p --agent reviewer "审查这个分支上的改动" 绑定的 Agent 即会话的身份:在会话首次绑定后即固定,之后不可切换。在 TUI 中,这些 flag 只绑定启动时的会话;之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。 -定制主 Agent 时,在正文中引用 `${base_prompt}` 可保持有效默认提示词中已有的环境、工作区指令、Skill 和 plugin 注入生效。如果要替换默认提示词、但只保留 plugin 提供的指令,请改用 `${plugin_sections}`。正文同时不引用 `${base_prompt}` 和 `${plugin_sections}` 时,会完全拥有自己的提示词并排除 plugin 指令,适合自包含的子 Agent。 +定制 main agent 时,在正文中引用 `${base_prompt}` 可保持有效默认提示词中已有的环境、工作区指令、Skill 和 plugin 注入生效。如果要替换默认提示词、但只保留 plugin 提供的指令,请改用 `${plugin_sections}`。正文同时不引用 `${base_prompt}` 和 `${plugin_sections}` 时,会完全拥有自己的提示词并排除 plugin 指令,适合自包含的 subagent。 -### 用 SYSTEM.md 覆盖主 Agent 的系统提示词 +### 用 SYSTEM.md 覆盖 main agent 的系统提示词 -希望永久覆盖主 Agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认主 Agent 的系统提示词——但只替换提示词,描述、工具集与允许委派的子 Agent 列表仍沿用内置默认值。SYSTEM.md 在包括交互式 TUI 会话在内的所有启动方式下生效。 +希望永久覆盖 main agent 的系统提示词、而不必每次启动都传入 `--agent` 或 `--agent-file` 时,可以写一份 `$KIMI_CODE_HOME/SYSTEM.md`(默认:`~/.kimi-code/SYSTEM.md`,随 `KIMI_CODE_HOME` 移动)。文件存在且非空期间,它整体替换内置默认 main agent 的系统提示词——但只替换提示词,描述、工具集与允许委派的 subagent 列表仍沿用内置默认值。SYSTEM.md 在包括交互式 TUI 会话在内的所有启动方式下生效。 SYSTEM.md 是纯 Markdown 正文,不需要也不读取 Frontmatter。文件缺失或为空时不生效;读取失败时会告警并回退到内置提示词。优先级上,显式意图仍然胜出:项目作用域中声明了 `override: true` 的同名 Agent 文件、通过 `--agent-file` 传入的文件都排在 SYSTEM.md 之前,用 `--agent` 选择其他 Agent 时 SYSTEM.md 也不会生效;而在用户作用域内部,SYSTEM.md 优先于 `agents/` 目录中扫描到的同名文件。 @@ -180,7 +176,7 @@ ${plugin_sections} ## 会话目录中的存储位置 -子 Agent 的运行状态持久化到当前会话目录的 `agents/` 子目录下,每个子 Agent 实例对应一个独立目录,其中包含按时间顺序记录提示词、消息历史与最终状态的 `wire.jsonl` 文件。后台子 Agent 还会通过 `tasks/` 子目录暴露生命周期状态。 +subagent 的运行状态持久化到当前会话目录的 `agents/` 子目录下,每个 subagent 实例对应一个独立目录,其中包含按时间顺序记录提示词、消息历史与最终状态的 `wire.jsonl` 文件。后台 subagent 还会通过 `tasks/` 子目录暴露生命周期状态。 ::: warning 注意 会话目录、wire 文件和任务记录都属于本地调试材料,可能包含用户 prompt、命令输出、仓库路径、工具返回内容或凭证痕迹。不要把这些文件直接提交到公开仓库、issue 或聊天记录里;如确需分享,请先脱敏。 @@ -188,5 +184,5 @@ ${plugin_sections} ## 下一步 -- [Hooks](./hooks.md) — 在子 Agent 完成等关键节点触发本地脚本通知或拦截 -- [Agent Skills](./skills.md) — 给子 Agent 注入专业知识和工作流程 +- [Hooks](./hooks.md) — 在 subagent 完成等关键节点触发本地脚本通知或拦截 +- [Agent Skills](./skills.md) — 给 subagent 注入专业知识和工作流程 diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index 93c2206731b..b23ec914314 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -112,8 +112,8 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上 | `SessionStart` | `startup` 或 `resume` | — | 新会话启动或历史会话恢复后触发;payload 含 `source`、`model` 和 `profile` | | `SessionEnd` | `exit` 或 `archive` | — | 会话关闭后触发;`archive` 表示会话被归档而非退出 | | `SessionHeartbeat` | 空字符串 | — | 会话存活期间每 60 秒触发一次;仅当配置了本事件时计时器才会运行。payload 含 `uptime_ms`(观察用) | -| `SubagentStart` | 子 Agent 名称 | — | 子 Agent 开始运行前触发 | -| `SubagentStop` | 子 Agent 名称 | — | 子 Agent 成功完成后触发(观察用) | +| `SubagentStart` | subagent 名称 | — | subagent 开始运行前触发 | +| `SubagentStop` | subagent 名称 | — | subagent 成功完成后触发(观察用) | | `TaskStarted` | 任务类型(`agent`、`process` 或 `question`) | — | 后台任务启动时触发;payload 含 `task_id`、`description` 和 `detached`(观察用) | | `StopFailure` | 错误类型 | — | 本轮因错误失败后触发(观察用) | | `Interrupt` | 空字符串 | — | 用户中断本轮时触发(例如按下 Esc);超时或其他程序性中断不会触发。中断时 `Stop` 不会触发,由本事件替代。payload 含 `reason` 字段(观察用) | @@ -160,4 +160,4 @@ process.stdin.on('end', () => { ## 下一步 - [配置](#配置) — `[[hooks]]` 在 `config.toml` 中的完整字段声明 -- [Agent 与子 Agent](./agents.md) — 利用 `SubagentStop` 事件在子 Agent 完成后触发通知 +- [Agent 与 subagent](./agents.md) — 利用 `SubagentStop` 事件在 subagent 完成后触发通知 diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index bfc6fd4bb12..cb16972132d 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -23,6 +23,8 @@ MCP server 配置写在 `mcp.json` 中,分两层: 从配置中删除某个 server 不会打断进行中的会话:该 server 在 `/mcp` 中仍显示为 `removed`,其工具在这些会话中保持可见,但调用会失败并返回移除提示;新会话则完全不会注册这些工具。反过来,会话进行中新增的 server——无论是编辑 `mcp.json` 还是安装 plugin——都不会注册到已打开的会话中,只会加入之后创建的会话。 +当 Kimi Code 在不受信任的文件夹中发现项目级 MCP server 时,工作区信任提示会显示每个 server 的传输方式和启动目标。提示默认选中 `Don't trust`;请先移动到 `Trust this folder`,核对列出的命令与参数或远程 URL 后,再确认信任。信任文件夹后,该工作区的项目级 MCP server 才会启用。 + `mcp.json` 的结构: ```json diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index aa46a04b49a..abf1ee6dea9 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -254,7 +254,7 @@ Plugin 是一个带 manifest 的目录或 zip 文件。Manifest 可以放在以 | `interface` | 在 `/plugins` 中展示的字段:`displayName`、`shortDescription`、`longDescription`、`developerName`、`websiteURL` | | `skills` | 一个或多个 `./` 路径,必须位于 plugin 根目录内。省略时根目录的 `SKILL.md` 被当作单个 Skill root | | `agents` | 一个或多个 `./` 路径,必须位于 plugin 根目录内,指向含有 [Agent 文件](./agents.md#自定义-agent)的目录。省略时根下的 `agents/` 目录(若存在)被自动采用 | -| `sessionStart.skill` | 在新会话或恢复会话开始时,把指定 plugin Skill 加载到主 Agent | +| `sessionStart.skill` | 在新会话或恢复会话开始时,把指定 plugin Skill 加载到 main agent | | `skillInstructions` | 每次加载此 plugin 的 Skill 时一并附带的额外说明 | | `systemPrompt` | plugin 启用期间提供给 Agent 系统提示词的内联指令 | | `systemPromptPath` | 指向 UTF-8 文本文件的 `./` 路径;同时设置 `systemPrompt` 时,文件内容拼接在内联指令之后 | @@ -281,7 +281,7 @@ Plugin 是一个带 manifest 的目录或 zip 文件。Manifest 可以放在以 新会话和新建 Agent 会读取当前已启用 plugin 的指令。正在进行的请求会继续使用已有的系统提示词。`/plugins reload` 会刷新 plugin Skill 列表,并请求重建活跃 Agent 的提示词;如果需要让变更在下一轮前明确收敛,请使用这个命令。在 v2 引擎中,安装、启用、禁用或移除 plugin 会立即更新 catalog,后续的提示词重建(例如压缩上下文或修改工具策略后)可能会读取新的指令。legacy 引擎会让每个活跃 session 保留自己的 plugin 快照,直到 `/plugins reload` 或创建新 session。从磁盘恢复的 session 会先使用持久化的提示词,后续重建再遵循对应引擎的行为。切换 plugin 的 MCP server 不会改变系统提示词指令。 -内置 Agent 提示词会自动包含已启用 plugin 的指令。自定义 `SYSTEM.md` 或 Agent 文件完全拥有自己的模板,因此应在希望出现 plugin 指令的位置加入 `${plugin_sections}`。如果自定义模板包含 `${base_prompt}`,且该有效默认提示词已经包含 plugin 块,就不要再重复加入 `${plugin_sections}`。完整变量表见 [自定义 Agent 与 SYSTEM.md](./agents.md#用-system-md-覆盖主-agent-的系统提示词)。 +内置 Agent 提示词会自动包含已启用 plugin 的指令。自定义 `SYSTEM.md` 或 Agent 文件完全拥有自己的模板,因此应在希望出现 plugin 指令的位置加入 `${plugin_sections}`。如果自定义模板包含 `${base_prompt}`,且该有效默认提示词已经包含 plugin 块,就不要再重复加入 `${plugin_sections}`。完整变量表见 [自定义 Agent 与 SYSTEM.md](./agents.md#用-system-md-覆盖-main-agent-的系统提示词)。 ## 插件斜杠命令 @@ -359,13 +359,13 @@ my-plugin/ SKILL.md ``` -`sessionStart.skill` 在会话启动时把一个 plugin Skill 加载到主 Agent,适合放置初始化说明、工作流规则,或把其他工具中的术语映射到 Kimi Code CLI。它只注入文本,不执行代码。 +`sessionStart.skill` 在会话启动时把一个 plugin Skill 加载到 main agent,适合放置初始化说明、工作流规则,或把其他工具中的术语映射到 Kimi Code CLI。它只注入文本,不执行代码。 无论 Skill 通过哪种方式加载(`sessionStart.skill`、`/skill:` 或模型自动调用),`skillInstructions` 都会随该 plugin 的 Skill 一起出现。 ## 插件 Agent -Plugin 可以携带自定义 Agent:在 manifest 的 `agents` 字段里声明一个或多个 `./` 目录(或直接在 plugin 根下放置 `agents/` 目录),其中的 Agent 文件与[自定义 Agent](./agents.md#自定义-agent) 格式相同,会在 plugin 启用期间作为子 Agent 被主 Agent 自动发现和委派。 +Plugin 可以携带自定义 Agent:在 manifest 的 `agents` 字段里声明一个或多个 `./` 目录(或直接在 plugin 根下放置 `agents/` 目录),其中的 Agent 文件与[自定义 Agent](./agents.md#自定义-agent) 格式相同,会在 plugin 启用期间作为 subagent 被 main agent 自动发现和委派。 ```text my-plugin/ diff --git a/docs/zh/customization/skills.md b/docs/zh/customization/skills.md index 8fd45fa1786..a6472210a1e 100644 --- a/docs/zh/customization/skills.md +++ b/docs/zh/customization/skills.md @@ -127,4 +127,4 @@ arguments: ## 下一步 - [Plugins](./plugins.md) — 把 Skills 打包成可安装单元,与团队共享 -- [Agent 与子 Agent](./agents.md) — Skills 如何影响子 Agent 的行为 +- [Agent 与 subagent](./agents.md) — Skills 如何影响 subagent 的行为 diff --git a/docs/zh/guides/server.md b/docs/zh/guides/server.md new file mode 100644 index 00000000000..38657b80e5e --- /dev/null +++ b/docs/zh/guides/server.md @@ -0,0 +1,116 @@ +# 本地服务与 API + +Kimi Code CLI 内置一个本地服务:运行 `kimi web` 会在前台启动一个进程,同时挂载浏览器里的 web UI、REST API(`/api/v1`)和 WebSocket 事件流(`/api/v1/ws`)。web UI 用于在浏览器里直接使用 Kimi Code;REST 与 WebSocket API 面向脚本和第三方工具,可以用代码创建会话、提交提示词、实时跟进执行过程——它们与 TUI、web UI 读写同一份会话数据。 + +> 开始前请确认 Kimi Code CLI 已安装并处于可用状态——完成 `/login` 登录(TUI 内或 `kimi login`),或已在 `config.toml` 配置供应商。服务与 CLI 共享同一份登录态与配置,无需为服务单独准备凭证。 + +::: warning 注意 +本页介绍的 REST 与 WebSocket API 为实验性特性:不保证接口稳定性,端点、字段与事件类型可能随版本随时更改。集成时请以当前版本服务的 `/openapi.json` 与 `/asyncapi.json` 为准。 +::: + +## 启动服务 + +```sh +kimi web # 前台运行服务并打开浏览器 +kimi web --no-open # 只运行服务,不打开浏览器 +kimi web --port 58628 # 指定绑定端口 +``` + +服务默认绑定 `127.0.0.1:58627`(仅本机访问);端口被占用时自动 +1 重试,同一台机器因此可以并存多个实例,每个实例登记在 `~/.kimi-code/server/instances/` 下。启动横幅会打印访问地址和明文 token: + +```text +Local: http://127.0.0.1:58627/#token=... +Token: ... +Stop: Ctrl+C +``` + +服务在前台运行,按 `Ctrl-C` 干净退出。`--host`、`--log-level` 等完整选项见 [kimi 命令参考](../reference/kimi-command.md#kimi-web)。 + +## 鉴权 + +所有 `/api/*` 接口都要求 bearer token(持有者令牌:任何携带该字符串的请求都被视为已授权)。token 在首次启动服务时生成,持久化在 `~/.kimi-code/server.token`(文件权限 0600),跨重启复用。 + +按客户端类型选择携带方式: + +- **REST**:请求头 `Authorization: Bearer `。 +- **web UI**:启动横幅里的地址自带 `#token=` 片段,浏览器打开后自动完成登录;该片段不会发送到服务端。 +- **WebSocket**:能自定义请求头的客户端用 `Authorization: Bearer`;浏览器等不能自定义头的客户端改用子协议(WebSocket 握手时声明的协议名)`kimi-code.bearer.`。 + +token 泄露时运行 `kimi web rotate-token` 轮换:新 token 立即写入 `server.token`,旧 token 即刻失效,正在运行的实例无需重启。 + +如果把服务绑定到非本机地址(`--host`),建议额外设置 `KIMI_CODE_PASSWORD` 环境变量作为并列凭证;此时服务端会对鉴权失败自动限流。 + +::: danger 警告 +`--dangerous-bypass-auth` 会彻底关闭鉴权,任何能访问该端口的人都能控制你的会话、文件系统和 shell。仅在可信网络或自有鉴权代理之后使用,详见 [kimi 命令参考](../reference/kimi-command.md#kimi-web)。 +::: + +## 用 API 驱动一个会话 + +下面用 curl 走一遍最小流程:确认服务状态 → 创建会话 → 订阅事件 → 提交提示词 → 回读历史。示例假设服务跑在默认地址,token 已存入 shell 变量 `TOKEN`。 + +1. 确认服务状态: + +```sh +curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:58627/api/v1/meta +``` + +所有 JSON 响应都包在统一信封里——`{ "code": 0, "msg": "success", "data": ..., "request_id": "..." }`,业务结果以 `code` 为准(`0` 表示成功),HTTP 状态码只表达传输层结果。 + +2. 创建会话,`metadata.cwd` 指定工作目录: + +```sh +curl -s -X POST http://127.0.0.1:58627/api/v1/sessions \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"metadata": {"cwd": "/path/to/project"}}' +``` + +返回的 `data.id`(形如 `session_...`)就是后续所有请求要用的会话 id。 + +3. 连接 WebSocket 并订阅会话事件。任何 WebSocket 客户端都可以;下面是一个零依赖的 Node.js 脚本(Node.js 22+ 内置 `WebSocket` 客户端): + +```js +// subscribe.mjs —— 用法:TOKEN=... node subscribe.mjs session_... +const ws = new WebSocket('ws://127.0.0.1:58627/api/v1/ws', [ + `kimi-code.bearer.${process.env.TOKEN}`, +]); +ws.onmessage = (e) => console.log(e.data); +ws.onopen = () => + ws.send( + JSON.stringify({ + type: 'subscribe', + id: '1', + payload: { session_ids: [process.argv[2]] }, + }), + ); +``` + +4. 提交提示词: + +```sh +curl -s -X POST http://127.0.0.1:58627/api/v1/sessions//prompts \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"content": [{"type": "text", "text": "用一句话介绍这个仓库"}]}' +``` + +订阅端会依次看到 `turn.started`(轮次开始)→ `assistant.delta`(流式文本增量)→ 发生工具调用时的 `tool.call.started` / `tool.result` → `turn.ended`(轮次结束)。 + +5. 随时可以用 REST 回读历史消息: + +```sh +curl -s -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:58627/api/v1/sessions//messages?page_size=20" +``` + +## 在线规范文档 + +服务运行时会自描述两份规范文档,同样需要 bearer token: + +- `GET /openapi.json` — REST API 的 OpenAPI 文档,含每个端点的请求 / 响应 schema,可直接导入 Swagger UI、Postman 等工具。 +- `GET /asyncapi.json` — WebSocket 协议的 AsyncAPI 文档,覆盖控制帧与事件类型。 + +## 下一步 + +- [服务 API](../reference/server-api.md) — REST 端点全集、错误码、WebSocket 事件与转录协议 +- [kimi 命令](../reference/kimi-command.md#kimi-web) — `kimi web` 的全部命令行选项 diff --git a/docs/zh/guides/use-cases.md b/docs/zh/guides/use-cases.md index bfd1a93bcac..9318b94fdee 100644 --- a/docs/zh/guides/use-cases.md +++ b/docs/zh/guides/use-cases.md @@ -24,7 +24,7 @@ src/runtime 下的 event loop 是怎么工作的?事件从哪里产生、又 这个项目里「权限审批」是怎么实现的?涉及哪些文件,关键类型是什么? ``` -大型调研可以让主 Agent 派发**子 Agent** 并行处理子任务,详见 [Agent 与子 Agent](../customization/agents.md)。 +大型调研可以让 main agent 派发**subagent** 并行处理子任务,详见 [Agent 与 subagent](../customization/agents.md)。 ## 实现新功能 @@ -143,6 +143,6 @@ src/api 下所有公开函数里,凡是没有 docstring 的都补上文档注 ## 下一步 -- [Agent 与子 Agent](../customization/agents.md) — 如何让 Agent 派发子任务并行处理 +- [Agent 与 subagent](../customization/agents.md) — 如何让 Agent 派发子任务并行处理 - [Hooks](../customization/hooks.md) — 在任务完成等节点触发本地脚本 - [内置工具](../reference/tools.md) — Agent 可调用的全部工具参考 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 642026c799d..345e74d10b8 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -24,7 +24,7 @@ kimi [options] | `--auto` | | 以 auto 权限模式启动;工具审批自动处理,Agent 不会向用户提问 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | -| `--agent ` | | 以指定 Agent 作为主 Agent 启动新会话。不能与 `--session`/`--continue` 同时使用 | +| `--agent ` | | 以指定 Agent 作为 main agent 启动新会话。不能与 `--session`/`--continue` 同时使用 | | `--agent-file ` | | 从 Markdown 文件加载自定义 Agent 并为新会话选中它。不可重复传入,也不能与 `--agent`、`--session` 或 `--continue` 同时使用 | | `--add-dir ` | | 为本次会话添加额外的工作目录。相对路径按当前工作目录解析。可重复传入 | @@ -105,7 +105,7 @@ kimi --agent reviewer kimi -p --agent reviewer "审查这个分支上的改动" ``` -`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥。两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合,因为 Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent。选择在会话首次绑定后即固定,之后不可切换;在 TUI 中,这些 flag 只绑定启动时的会话,之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 +`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥。两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合,因为 Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent。选择在会话首次绑定后即固定,之后不可切换;在 TUI 中,这些 flag 只绑定启动时的会话,之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。Agent 文件格式与发现目录详见 [Agent 与 subagent](../customization/agents.md#自定义-agent)。 ## 非交互执行 @@ -157,7 +157,7 @@ kimi acp 在当前终端前台运行本地 Kimi 服务 —— 同一个进程同时挂载 REST + WebSocket API 与 web UI —— 并在服务就绪后用默认浏览器打开 web UI。命令会一直挂在终端,直到收到 `SIGINT` / `SIGTERM`(如 `Ctrl-C`)时干净退出。 -服务运行时,`GET /openapi.json` 会返回 REST OpenAPI 文档,`GET /asyncapi.json` 会返回本地 WebSocket 协议的 AsyncAPI 文档。 +服务运行时,`GET /openapi.json` 会返回 REST OpenAPI 文档,`GET /asyncapi.json` 会返回本地 WebSocket 协议的 AsyncAPI 文档。用 API 驱动会话的完整流程见[本地服务与 API](../guides/server.md),协议细节见[服务 API](./server-api.md)。 ```sh kimi web # 前台运行服务并打开浏览器 @@ -380,4 +380,4 @@ kimi provider catalog add anthropic --api-key sk-ant-... --default-model claude- - [斜杠命令](./slash-commands.md) — 交互式 TUI 内的控制命令速查 - [配置文件](../configuration/config-files.md) — `default_model`、权限模式等启动参数的持久化配置 - [Agent Skills](../customization/skills.md) — `--skills-dir` 加载的 Skill 文件格式 -- [Agent 与子 Agent](../customization/agents.md) — 内置子 Agent、自定义 Agent 文件与通过 `--agent` 选择主 Agent +- [Agent 与 subagent](../customization/agents.md) — 内置 subagent、自定义 Agent 文件与通过 `--agent` 选择 main agent diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md new file mode 100644 index 00000000000..9ff6758e630 --- /dev/null +++ b/docs/zh/reference/server-api.md @@ -0,0 +1,344 @@ +# 服务 API + +`kimi web` 启动的本地服务暴露两组程序化接口:REST API(`/api/v1`,另有 `/api/v2/sessions`)和 WebSocket 事件流(`/api/v1/ws`)。本页是这两组接口的协议参考;服务的启动方式与命令行选项见 [kimi 命令](./kimi-command.md#kimi-web),端到端的上手流程见[本地服务与 API](../guides/server.md)。 + +每个端点的完整请求 / 响应 schema 以服务自描述的规范文档为准:`GET /openapi.json`(OpenAPI)与 `GET /asyncapi.json`(AsyncAPI),两者都需要鉴权。 + +::: warning 注意 +本页描述的 REST 与 WebSocket API 为实验性特性:不保证接口稳定性,端点、字段与事件类型可能随版本随时更改。集成时请以当前版本服务的 `/openapi.json` 与 `/asyncapi.json` 为准。 +::: + +## 基础约定 + +### 地址 + +默认地址 `http://127.0.0.1:58627`;端口被占用时自动 +1 重试(至多 100 次),可用 `--port` / `--host` 修改。同一 home 目录可并存多个实例,运行中的实例登记在 `~/.kimi-code/server/instances/`。 + +### 鉴权 + +除以下例外,所有 `/api/*` 路径(含 `/openapi.json` 与 `/asyncapi.json`)都要求 bearer token: + +- `OPTIONS` 预检请求 +- `GET /api/v1/healthz`(探活) +- 静态 web 资源(非 `/api/` 路径) + +携带方式:REST 用 `Authorization: Bearer ` 请求头;WebSocket 升级请求可用同一请求头,或子协议 `kimi-code.bearer.`。token 的生成与轮换见[本地服务与 API:鉴权](../guides/server.md#鉴权)。 + +鉴权失败返回 HTTP 401,信封 `code` 为 `40101`。在非 loopback 绑定上,同一来源 60 秒内鉴权失败 10 次会被封禁 60 秒,期间一律返回 HTTP 429(`code` 为 `42901`)。 + +### 响应信封 + +所有 JSON 响应统一包在信封里: + +```json +{ + "code": 0, + "msg": "success", + "data": {}, + "request_id": "01JZX4A6E7M8V0R3Q0N2K2M5Q9" +} +``` + +- `code`:业务结果,`0` 表示成功;错误码分段见下文。 +- `data`:成功时的业务数据。注意部分「错误」信封也携带非空 `data`——例如重复解决审批返回 `40902` 且 `data.resolved` 为 `false`——客户端应先判 `code` 再看 `data`。 +- `request_id`:本次请求的 ULID;客户端可用 `X-Request-Id` 请求头指定,非法值会被服务端重新生成。 + +HTTP 状态码几乎总是 200,业务结果以 `code` 为准。例外情况: + +| 场景 | HTTP 状态 | +| --- | --- | +| 鉴权失败 / 触发限流 | 401 / 429 | +| 创建供应商、导入供应商目录成功 | 201 | +| 删除供应商成功 | 204 | +| 二进制与流式端点 | 支持时返回 206(Range 分段)/ 304(ETag 未变),各端点能力不同,详见「[二进制与流式端点](#二进制与流式端点)」 | +| `GET /api/v1/files/{file_id}` 下载错误 | 真实 404 / 500(响应体仍为信封) | + +其中 201 的响应体仍是标准信封(`code` 为 `0`),只是状态行遵循 REST 的资源创建习惯;204 按 HTTP 语义没有响应体,删除成功以状态码本身为准。 + +### 错误码 + +错误码按段位分组: + +| 段位 | 含义 | 示例 | +| --- | --- | --- | +| `0` | 成功 | | +| `400xx` | 请求参数错误 | `40001` 校验失败(`details` 逐字段说明)、`40003` 供应商由 OAuth 托管 | +| `401xx` | 鉴权与就绪状态 | `40101` 未授权、`40110` 未配置供应商、`40113` 模型未解析 | +| `404xx` | 资源不存在 | `40401` 会话、`40408` MCP 服务、`40409` 文件路径 | +| `409xx` | 状态冲突 | `40901` 会话忙、`40902` 审批已解决、`40922` 分页条件与 `page_token` 不符 | +| `410xx` | 资源已过期 | `41001` 审批超时、`41002` 提问超时、`41003` 临时文件过期 | +| `413xx` | 体积或边界超限 | `41302` 读取文件超 10 MB、`41304` 路径越出会话目录 | +| `429xx` | 限流 | `42901` 鉴权失败封禁、`42902` 文件监听数超限 | +| `500xx` | 服务端内部错误 | `50001` 未捕获异常、`50003` 持久化失败 | +| `6xxxx` / `7xxxx` / `8xxxx` | 工具运行时 / LLM 供应商 / MCP 透传错误,`msg` 保留上游原文 | | + +### 分页 + +列表端点有两种分页风格: + +- **游标式**:`before_id` / `after_id`(互斥)加 `page_size`(1–100),响应为 `{ items, has_more }`。用于会话列表、消息列表、转录等。 +- **`page_token`**:不透明令牌(内部绑定了查询条件指纹),用于 `POST /api/v1/search` 与 `GET /api/v2/sessions`。翻页途中改变任何查询条件会使令牌失效:v2 返回 `40922`,search 返回 `40001`。 + +## REST 端点 + +按资源分组列出端点。路径里的 `:{action}` 是动作后缀约定——对单个资源 POST 到 `路径:动作` 执行非 CRUD 操作(如会话的 `:fork`、`:archive`)。 + +### 服务与元信息 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/healthz` | 探活,免鉴权 | +| `GET /api/v1/meta` | 服务版本、能力集、`server_id`、实验开关等 | +| `POST /api/v1/shutdown` | 优雅退出(先回 200 再关闭);仅 loopback 绑定时挂载 | + +### 登录与用量 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/auth` | 登录就绪状态快照 | +| `POST /api/v1/oauth/login` | 发起 OAuth device-code 登录流程 | +| `GET /api/v1/oauth/login` | 轮询登录流程状态 | +| `DELETE /api/v1/oauth/login` | 取消进行中的登录流程 | +| `POST /api/v1/oauth/logout` | 登出托管供应商 | +| `GET /api/v1/oauth/usage` | 查询套餐用量与限额 | +| `GET /api/v1/oauth/userinfo` | 查询账号资料 | + +### 配置 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/config` | 读取全局配置(密钥字段脱敏) | +| `POST /api/v1/config` | 合并式更新配置,并广播 `event.config.changed` | + +### 模型与供应商 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/models` | 列出已配置的模型别名 | +| `POST /api/v1/models/{model_id}:set_default` | 设置全局默认模型 | +| `GET /api/v1/providers` | 列出供应商 | +| `POST /api/v1/providers` | 创建供应商(201) | +| `GET /api/v1/providers/{provider_id}` | 读取供应商(含已存密钥) | +| `PUT /api/v1/providers/{provider_id}` | 整体替换供应商配置 | +| `DELETE /api/v1/providers/{provider_id}` | 删除供应商(204) | +| `POST /api/v1/providers/{provider_id}:refresh` | 刷新该供应商的模型元数据 | +| `POST /api/v1/providers:{action}` | 集合级动作:`refresh` / `refresh_oauth` / `import_catalog` / `import_registry` | +| `GET /api/v1/catalog/providers` | 浏览 models.dev 目录(服务端代理) | +| `GET /api/v1/catalog/providers/{catalog_id}` | 读取目录中单个条目 | + +### 会话 + +| 方法与路径 | 说明 | +| --- | --- | +| `POST /api/v1/sessions` | 创建会话(需 `workspace_id` 或 `metadata.cwd`) | +| `GET /api/v1/sessions` | 列出会话,游标分页,支持 `busy` / `archived_only` 等过滤 | +| `GET /api/v1/sessions/{session_id}` | 读取单个会话 | +| `GET /api/v1/sessions/{session_id}/profile` | 读取会话档案 | +| `POST /api/v1/sessions/{session_id}/profile` | 更新标题、元数据、agent 配置 | +| `POST /api/v1/sessions/{session_id}:{action}` | 会话动作:`fork` / `compact` / `undo` / `abort` / `btw` / `archive` / `restore` | +| `GET /api/v1/sessions/{session_id}/children` | 列出子会话 | +| `POST /api/v1/sessions/{session_id}/children` | 创建子会话(fork 并打标) | +| `GET /api/v1/sessions/{session_id}/status` | 实时状态汇总 | +| `GET /api/v1/sessions/{session_id}/goal` | 当前目标快照(无则 `null`) | +| `GET /api/v1/sessions/{session_id}/warnings` | 会话级告警 | +| `POST /api/v1/sessions/{session_id}/export` | 导出会话与诊断信息(zip 流,不走信封) | +| `GET /api/v1/sessions/{session_id}/snapshot` | 客户端重建用全量快照(含 `as_of_seq` 与 `epoch`) | + +### 消息与转录 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/messages` | 消息分页(`before_id` / `after_id` / `role`) | +| `GET /api/v1/sessions/{session_id}/messages/{message_id}` | 读取单条消息 | +| `GET /api/v1/sessions/{session_id}/transcript` | 转录按轮次分页(需 `agent_id`),全局状态不分页随响应返回 | +| `GET /api/v1/sessions/{session_id}/transcript/ops` | 转录批次补漏(`since_seq`),`complete: false` 时需全量刷新 | +| `GET /api/v1/sessions/{session_id}/transcript/user-messages` | 各轮次的用户输入,不分页 | +| `GET /api/v1/sessions/{session_id}/transcript/plan` | ExitPlanMode 计划内容、路径与审阅结果 | + +### 提示词 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/prompts` | 进行中与排队中的提示词 | +| `POST /api/v1/sessions/{session_id}/prompts` | 提交提示词(内容块数组,可带模型 / 权限模式等覆盖) | +| `POST /api/v1/sessions/{session_id}/prompts:steer` | 把排队的提示词插入当前轮次 | +| `POST /api/v1/sessions/{session_id}/prompts/{prompt_id}:abort` | 中止进行中的提示词 | +| `POST /api/v1/sessions/{session_id}/prompts/{prompt_id}:steer` | 插入单个排队提示词 | + +### 审批与提问 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/approvals` | 列出审批请求(可按 `status=pending` 过滤) | +| `POST /api/v1/sessions/{session_id}/approvals/{approval_id}` | 答复审批 | +| `GET /api/v1/sessions/{session_id}/questions` | 列出提问 | +| `POST /api/v1/sessions/{session_id}/questions/{question_id}` | 回答提问 | +| `POST /api/v1/sessions/{session_id}/questions/{question_id}:dismiss` | 忽略提问 | + +### 后台任务 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/tasks` | 列出后台任务 | +| `GET /api/v1/sessions/{session_id}/tasks/{task_id}` | 读取任务(可选输出预览) | +| `POST /api/v1/sessions/{session_id}/tasks/{task_id}:cancel` | 取消任务 | + +### 技能、工具与 MCP + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/skills` | 会话级技能目录 | +| `GET /api/v1/workspaces/{workspace_id}/skills` | 无会话的工作区技能目录 | +| `POST /api/v1/sessions/{session_id}/skills/{skill_name}:activate` | 激活技能(开启一个轮次) | +| `GET /api/v1/tools` | 列出当前生效 agent 的工具 | +| `GET /api/v1/mcp/servers` | 列出 MCP 服务 | +| `POST /api/v1/mcp/servers/{mcp_server_id}:restart` | 重启 MCP 服务 | + +### 终端 + +PTY 终端接口,仅 loopback 绑定时挂载。 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/sessions/{session_id}/terminals` | 列出终端 | +| `POST /api/v1/sessions/{session_id}/terminals` | 创建终端 | +| `GET /api/v1/sessions/{session_id}/terminals/{terminal_id}` | 读取终端(含回滚缓冲) | +| `POST /api/v1/sessions/{session_id}/terminals/{terminal_id}:close` | 关闭终端 | + +### 工作区 + +| 方法与路径 | 说明 | +| --- | --- | +| `GET /api/v1/workspaces` | 列出已注册工作区 | +| `POST /api/v1/workspaces` | 注册工作区(按根路径幂等) | +| `PATCH /api/v1/workspaces/{workspace_id}` | 重命名 | +| `DELETE /api/v1/workspaces/{workspace_id}` | 注销(保留磁盘内容) | +| `GET /api/v1/workspaces/{workspace_id}/trust` | 读取信任状态 | +| `POST /api/v1/workspaces/{workspace_id}/trust` | 授予信任 | +| `POST /api/v1/workspaces/{workspace_id}/untrust` | 撤销信任 | + +### 文件系统 + +会话内文件操作为 `POST /api/v1/sessions/{session_id}/fs:{action}`,动作包括 `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `diff` / `open` / `open-in` / `reveal`,请求体为 JSON。另有: + +| 方法与路径 | 说明 | +| --- | --- | +| `POST /api/v1/workspace/fs:search` | 无会话的工作区搜索(body 携带工作区引用) | +| `GET /api/v1/sessions/{session_id}/fs/{path}:download` | 下载会话文件(二进制,见下文) | +| `GET /api/v1/fs:browse` | 列出本机目录(文件夹选择器用) | +| `GET /api/v1/fs:home` | 用户主目录与最近工作区 | +| `GET /api/v1/fs:content` | 读取本机任意文件原始字节(仅受 token 保护,谨慎暴露端口) | +| `POST /api/v1/fs:mkdir` | 按绝对路径创建目录 | + +### 文件上传 + +| 方法与路径 | 说明 | +| --- | --- | +| `POST /api/v1/files` | multipart 上传(字段 `file`,可选 `name`、`expires_in_sec`),返回文件元信息 | +| `GET /api/v1/files/{file_id}` | 下载(二进制,错误用真实 HTTP 状态码) | +| `DELETE /api/v1/files/{file_id}` | 删除 | + +### 全局搜索与其他 + +| 方法与路径 | 说明 | +| --- | --- | +| `POST /api/v1/search` | 跨会话全文搜索,`mode` 为 `terms`(默认)或 `literal`(精确子串),`page_token` 分页 | +| `GET /api/v1/connections` | 列出当前在线的 WebSocket 连接 | +| `GET /api/v2/sessions` | 新一代会话列表,见下节 | +| `/api/v1/debug/*` | 反射式调试 RPC,仅 `--debug-endpoints` 且 loopback 时挂载,不属于稳定协议 | + +### `GET /api/v2/sessions` + +面向列表页的新一代会话查询,筛选、排序、字段组都在查询参数里: + +| 参数 | 说明 | +| --- | --- | +| `workspace.id` | 按工作区过滤,可重复 | +| `activity.status` | 按活动状态过滤:`running` / `approval` / `question` / `failed` / `idle`,可重复 | +| `meta.updated_after` | 只看该时间(epoch 毫秒)之后更新过的会话 | +| `meta.archived` | `true` / `false`(默认)/ `all` | +| `sort` | `meta.updated_at_desc`(默认)/ `meta.updated_at_asc` / `meta.created_at_desc` | +| `include` | 逗号分隔的附加字段组;目前支持 `git`(分支与 PR 信息,按目录去重并缓存 60 秒) | +| `page_size` | 1–100,默认 50 | +| `page_token` | 上一页返回的翻页令牌 | + +响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组。翻页令牌绑定首页查询条件,中途改条件返回 `40922`。 + +## WebSocket 协议 + +### 建立连接 + +唯一端点是 `ws://:/api/v1/ws`,升级请求即完成鉴权(方式见上文「鉴权」)。连接建立后服务端立即发送 `server_hello`: + +```json +{ + "type": "server_hello", + "timestamp": "2026-01-01T00:00:00.000Z", + "payload": { + "ws_connection_id": "conn_01JZX4...", + "protocol_version": 2, + "max_event_buffer_size": 1000, + "capabilities": { "event_batching": false, "compression": false } + } +} +``` + +注意服务端不发送心跳,也不会主动断开空闲连接——保活与重连由客户端自己负责。 + +### 控制帧 + +客户端发送 JSON 帧 `{ "type", "id"?, "payload" }`;每个请求帧都会收到应答 `{ "type": "ack", "id", "code", "msg", "payload" }`,`code` 为 `0` 表示成功。 + +| 帧 | payload | 说明 | +| --- | --- | --- | +| `subscribe` | `{ session_ids, cursors?, agent_filter? }` | 订阅会话事件;带 `cursors`(每会话 `{seq, epoch}`)时回放错过的持久事件 | +| `unsubscribe` | `{ session_ids }` | 取消会话订阅 | +| `subscribe_v2` | `{ session_id, transcript, transcript_since? }` | 订阅转录流(唯一的转录订阅通道),`transcript` 按 agent 指定粒度 | +| `unsubscribe_v2` | `{ session_id, agent_ids? }` | 退订转录流;省略 `agent_ids` 表示整个会话 | +| `watch_fs_add` / `watch_fs_remove` | `{ session_id, paths, recursive? }` | 订阅 / 取消文件变更通知(`event.fs.changed`) | +| `client_hello` | `{ client_id }` | 握手帧,其余字段为遗留兼容 | + +### 事件 + +事件帧形状为 `{ "type", "seq", "epoch"?, "volatile"?, "offset"?, "session_id"?, "timestamp", "payload" }`,`type` 即事件类型。按投递范围分两类: + +- **全局事件**:发送到每个已建立连接,无需订阅——`session.meta.updated`、`event.session.created`、`event.session.work_changed`、`event.session.status_changed`、`event.workspace.*`、`event.config.*`。 +- **会话事件**:只发给订阅了该会话的连接,受 `agent_filter` 过滤。主要事件族: + +| 事件族 | 主要事件 | +| --- | --- | +| 轮次 | `turn.started`、`turn.ended`、`turn.step.started` / `completed` / `interrupted` / `retrying` | +| 流式文本 | `assistant.delta`、`thinking.delta`(带 `offset` 用于对齐) | +| 工具调用 | `tool.call.started`、`tool.call.delta`、`tool.progress`、`tool.result` | +| 交互 | `event.approval.requested` / `resolved`、`event.question.requested` / `answered` / `dismissed` | +| subagent | `subagent.spawned` / `started` / `suspended` / `completed` / `failed` | +| 后台 | `task.started` / `terminated`、`shell.started` / `output` / `completed` | +| 其他 | `compaction.*`、`skill.activated`、`goal.updated`、`prompt.*`、`error`、`warning` | + +事件另分持久与易失两种:持久事件带严格递增的 `seq`,落盘并可回放;易失事件(各 `*.delta`、`tool.progress`、`shell.*` 等)标 `volatile: true`,不回放。消费易失文本流时用 `offset`(该轮次内的累计字符偏移)与本地已累积文本比对:小于本地长度说明是重复帧,大于说明有缺漏、需走快照恢复。 + +### 断线恢复 + +重连后在 `subscribe` 的 `cursors` 里带上每个会话最后应用事件的 `{seq, epoch}`,服务端会回放缺口;落后超过缓冲(1000 条)或游标失效时改为收到 `resync_required`。此时调用 `GET /api/v1/sessions/{session_id}/snapshot` 拿全量快照(含 `as_of_seq` 与 `epoch`),再以新游标重新订阅。 + +### 转录协议 + +`subscribe_v2` 的 `transcript` 按 agent 指定粒度:`off` / `turn` / `block` / `delta`(键 `"*"` 表示默认粒度),粒度越高推送越细。粒度非 `off` 的 agent 走两帧推送:`transcript.reset`(基线快照,历史经 REST 分页回读)和 `transcript.ops`(增量批次,带每个 agent 连续递增的 `seq`);该 agent 的旧式事件在同一连接上被抑制,改由转录帧承载。断线时用 `transcript_since` 续传;服务端批次日志无法覆盖缺口时(REST 补漏返回 `complete: false`)需全量刷新。REST 侧对应 `GET .../transcript`(按轮次分页)与 `GET .../transcript/ops?since_seq=`(批次补漏)。 + +## 二进制与流式端点 + +以下端点返回二进制流而非 JSON 载荷,各端点的 HTTP 能力并不相同: + +| 方法与路径 | 说明 | Range 分段(206) | ETag / 304 | +| --- | --- | --- | --- | +| `GET /api/v1/files/{file_id}` | 下载已上传文件 | 支持 | 不支持(会发送 `etag` 头,但不处理 `If-None-Match`) | +| `GET /api/v1/sessions/{session_id}/fs/{path}:download` | 下载会话工作区文件 | 支持 | 支持 | +| `GET /api/v1/fs:content` | 读取本机任意文件(仅受 token 保护,谨慎暴露端口) | 支持 | 支持 | +| `POST /api/v1/sessions/{session_id}/export` | 导出会话与诊断信息(zip 流) | 不支持 | 不支持 | + +错误语义也不相同:`GET /api/v1/files/{file_id}` 对查找和存储失败返回真实 404 / 500 状态码(参数校验失败仍走 HTTP 200 信封),其余三个端点的所有失败都走标准[响应信封](#响应信封)——客户端在这三个端点上仍需检查信封中的 `code`。 + +## 下一步 + +- [本地服务与 API](../guides/server.md) — 启动、鉴权与端到端调用流程 +- [kimi 命令](./kimi-command.md#kimi-web) — `kimi web` 的全部命令行选项 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 7dfc81e9d8c..90a1d4bfc2f 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -16,7 +16,7 @@ | `/logout` | — | 清除当前所选账号的凭据 | 否 | | `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见[平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-—-交互式供应商管理) | 是 | | `/model` | — | 切换当前会话使用的 LLM 模型 | 是 | -| `/secondary_model` | — | 配置子 Agent 默认绑定的次主力模型(写入 [`[secondary_model]`](../configuration/config-files.md#secondary-model) 配置并在当前会话立即生效)。需开启 `secondary-model` 实验功能 | 是 | +| `/secondary-model` | `/subagent-model` | 选择 subagent 的默认模型(写入 `[secondary_model] default_model`,详见[subagent 模型池](../configuration/config-files.md#subagent-模型池))。在 subagent 模型池实验功能启用时可见 | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | | `/experiments` | `/experimental` | 打开实验功能面板 | 是 | | `/permission` | — | 选择权限模式 | 是 | @@ -102,7 +102,7 @@ Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 ` | 命令 | 别名 | 说明 | 随时可用 | | --- | --- | --- | --- | | `/help` | `/h`、`/?` | 显示快捷键和所有可用命令 | 是 | -| `/btw [问题]` | — | 在 fork 出的子 Agent 中打开旁路对话,不改变当前主 Agent 轮次;不带问题时会先打开面板等待输入 | 是 | +| `/btw [问题]` | — | 在 fork 出的 subagent 中打开旁路对话,不改变当前 main agent 轮次;不带问题时会先打开面板等待输入 | 是 | | `/usage` | — | 显示 token 用量、上下文占用以及配额信息 | 是 | | `/status` | — | 显示当前会话运行时状态:版本、模型、工作目录、权限模式等 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及连接状态 | 是 | diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 009ff3d052d..83fbdb0948f 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -84,14 +84,14 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | 工具 | 默认审批 | 说明 | | --- | --- | --- | -| `Agent` | 自动放行 | 派生子 Agent 执行子任务 | -| `AgentSwarm` | swarm mode 中自动放行,否则需审批 | 启动基于 item 的子 Agent,或恢复已有子 Agent | +| `Agent` | 自动放行 | 派生 subagent 执行子任务 | +| `AgentSwarm` | swarm mode 中自动放行,否则需审批 | 启动基于 item 的 subagent,或恢复已有 subagent | | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | -**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(`"secondary"` 表示 `[secondary_model] model` 配置的次主力模型,`"primary"` 表示主模型;resume 时无效;次主力模型实验功能启用后可用)。显式 `model` 会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`Agent`** 将子任务委托给 subagent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(仅在启用 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 实验功能并配置模型池后可用——`[secondary_model.models]` 表或仅一行 `default_model`:池中别名,或 `"primary"` 表示调用方自己运行的模型;resume 时无效)。未传入时 subagent 绑定池的 `default_model`;未配置模型池时,subagent 一律继承调用方模型。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待 subagent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到 main agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个 subagent 显示运行、等待、完成或失败状态以及已耗时长。subagent 体系细节见 [Agent 与 subagent](../customization/agents.md)。 -**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。传入 `model`(次主力模型实验功能启用后可用)可以让新启动的子 Agent 运行在 `[secondary_model] model` 配置的次主力模型(`"secondary"`)或主模型(`"primary"`)上。这项显式选择会覆盖所选 [Agent profile 的 `model_preference`](../customization/agents.md#agent-文件格式);两者均未设置时,已配置的次主力模型为默认值,未配置时则继承调用方模型。恢复的子 Agent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个子 Agent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的子 Agent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动 subagent,也可以通过 `resume_agent_ids` 恢复已有 subagent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的 subagent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的 subagent 使用的 profile;省略时默认使用 `coder`。传入 `model`(仅在启用 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 实验功能并配置模型池后可用——`[secondary_model.models]` 表或仅一行 `default_model`)可以让新启动的 subagent 运行在池中别名指定的模型或调用方自己的模型(`"primary"`)上。未传入时新启动的 subagent 绑定池的 `default_model`;未配置模型池时则继承调用方模型。恢复的 subagent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有 subagent。本工具最多支持 128 个 subagent,会等待全部 subagent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个 subagent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的 subagent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 **`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 @@ -133,6 +133,6 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 ## 下一步 -- [Agent 与子 Agent](../customization/agents.md) — `Agent` 工具的调度机制与上下文隔离 +- [Agent 与 subagent](../customization/agents.md) — `Agent` 工具的调度机制与上下文隔离 - [Hooks](../customization/hooks.md) — 在工具调用前后触发本地脚本 - [斜杠命令](./slash-commands.md) — TUI 内置控制命令速查 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 0a09e9537a2..ad4910a05db 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,52 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.36.0(2026-08-13) + +### 新功能 + +- 实验性的子 Agent 模型配置升级为模型池:现在可以在 `[secondary_model]` 中配置一组带描述的候选模型,由主 Agent 每次派生时按任务挑选。 + + 启动前设置 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`(或实验总开关 `KIMI_CODE_EXPERIMENTAL_FLAG=1`)即可启用。 + + 推荐用法: + + - 极简用法:在 TUI 中运行 `/secondary-model` 选择,或在 `config.toml` 中写一行 `default_model`,让所有子 Agent 默认跑同一个模型;再加 `force = true` 可彻底固定该选择,主 Agent 无法改选。 + - 配置命名模型池,并为每个别名写一句适用场景的描述——描述会展示给主 Agent 作为挑选依据: + + ```toml + [secondary_model] + default_model = "kimi-code/kimi-for-coding-highspeed" + [secondary_model.models] + "kimi-code/kimi-for-coding-highspeed" = "快速、便宜,适合日常重构、代码解释和小改动。" + "kimi-code/k3" = "擅长复杂推理与深度调试,难题选它。" + ``` + + 详见 [子 Agent 模型池文档](https://moonshotai.github.io/kimi-code/zh/configuration/config-files.html#subagent-模型池)。 +- 新增实验性全屏 TUI 模式,设置 `KIMI_CODE_TUI_FULL_SCREEN=1` 环境变量即可启用。 +- TUI 支持渲染 LaTeX 数学公式(`$…$` 与 `$$…$$`),消息中的公式会显示为 Unicode 公式。 + +### 修复 + +- 修复未信任工作区可在信任确认前植入同名 `fd`/`stty` 可执行文件的风险;信任提示现在展示项目 MCP 的启动目标,并默认拒绝信任。 +- 修复在严格的 OpenAI 兼容供应商(如 DeepSeek)下,模型思考阶段打断轮次后,后续每轮请求都报 400 错误的问题。 +- 修复 API 请求失败自动重试期间按 Ctrl+C 无反应的问题。 +- 修复了一些已知问题,并做了若干细节优化。更详细的变更记录见 [GitHub](https://github.com/MoonshotAI/kimi-code/blob/main/apps/kimi-code/CHANGELOG.md)。 + +## 0.35.0(2026-08-12) + +### 新功能 + +- 内置插件市场新增 Modern Web Guidance 插件,通过 `/plugins` 选择 Modern Web Guidance 安装。 +- `/tasks` 面板现实时展示后台子 Agent 的工作进度。 + +### 修复 + +- 修复 coder 子 Agent 默认可继续派生子 Agent 的问题。 +- 修复压缩后 token 数显示偏低的问题,现在与会话中看到的数字一致。 +- 修复 Windows 上的两处二进制植入风险。 +- 修复了一些已知问题,并做了若干细节优化。更详细的变更记录见 [GitHub](https://github.com/MoonshotAI/kimi-code/blob/main/apps/kimi-code/CHANGELOG.md)。 + ## 0.34.0(2026-08-06) ### 新功能 diff --git a/fork/PATCHES.md b/fork/PATCHES.md index 9bd36e889e5..356f70e890b 100644 --- a/fork/PATCHES.md +++ b/fork/PATCHES.md @@ -16,6 +16,7 @@ mark the row `merged-upstream` (keep the row as history). | fork infrastructure | `fork/infra` | Fork identity (npm name, update CDN → gh-pages), fork-sync / fork-release workflows, this tracking doc | — | — | not-submitting | | security review hardening | `review/fork-security-audit` | typescript-review pass over the fork delta: fs-watch logging + bounded path set, ACP socket error handler / connection cap / Windows boundary warning, provider quota status plumbing, module-hook path normalization, dependency CVE refresh | — | — | local | | language system-prompt fix | `fix/language-system-prompt` | Remove misleading "even after long stretches of English tool output" from system.md (both engines); add `[language]` config section with `reply_language` defaulting to `"en"`; inject `language_directive` template variable into every system prompt. See `fork/LANGUAGE-BUG.md` | — | [#1998](https://github.com/MoonshotAI/kimi-code/issues/1998) | local | +| esc-interrupt thinking drop | `fix/esc-interrupt-thinking-drop` | Drop thinking-only assistant messages at the projector layer in **both** engines (`agent-core` v1 + `agent-core-v2`) so an ESC-interrupted turn never leaves a content-less message that 400s the session. Upstream `#2819` fixes only the v2 OpenAI-legacy serializer, so the v1 half stays fork-only | — | [#2691](https://github.com/MoonshotAI/kimi-code/issues/2691) | local | Changesets under `.changeset/` deliberately name the upstream package `@moonshot-ai/kimi-code`, not the fork's `@mbuckaway/kimi-code`, so a change can @@ -35,3 +36,14 @@ changesets, so the stale name is harmless here. | 0.33.0-MB.1.1 | 0.33.0 | Release auto-merge falls back to a direct merge when rejected | | 0.33.0-MB.1.2 | 0.33.0 | Releases published from drafts; feedback tests aligned with fork identity | | 0.33.0-MB.1.3 | 0.33.0 | Upstream sync via the scheduled `fork-sync` workflow | +| 0.34.0-MB.1.0 | 0.34.0 | First 0.34.0-based release; upstream 0.34.0 sync | +| 0.34.0-MB.1.4 | 0.34.0 | Upstream 0.34.0 sync merge (PR #24); release-version guard (PR #23) | +| 0.34.0-MB.1.5 | 0.34.0 | Language system-prompt fix (#26) | +| 0.34.0-MB.1.6 | 0.34.0 | Scheduled `fork-sync` merge (2026-08-08) | +| 0.34.0-MB.1.7 | 0.34.0 | Esc-interrupt thinking drop, v1+v2 (#30) | +| 0.34.0-MB.1.8 | 0.34.0 | Fork web bundle ship (PR #32) | +| 0.34.0-MB.1.9 | 0.34.0 | Architecture docs (PR #34) | +| 0.34.0-MB.1.11 | 0.34.0 | Supermoon mode (#36) + supermoon web bundle ship (#37) | +| 0.34.0-MB.1.12 | 0.34.0 | Release self-heal CI (#38); web mode-menu fix bundle (#40); ci/release-independent-of-upstream merged (PR #41/#42) | +| 0.34.0-MB.1.13 | 0.34.0 | Fork web bundle ship (#44) | +| 0.36.0-MB.1.14 (pending) | 0.36.0 (upstream main tip `102984aa6`) | Upstream 0.36.0 sync: fork patches ported, `dist-web` bundle kept fork-side, supermoon `agent_config` behavior preserved at the kap-server edge (`sessionAgentConfig.ts`), esc-interrupt patch retained (v1 half has no upstream equivalent) | diff --git a/packages/acp-adapter/CHANGELOG.md b/packages/acp-adapter/CHANGELOG.md index eaca59808ee..82b80da4b55 100644 --- a/packages/acp-adapter/CHANGELOG.md +++ b/packages/acp-adapter/CHANGELOG.md @@ -1,5 +1,19 @@ # @moonshot-ai/acp-adapter +## 0.3.8 + +### Patch Changes + +- Updated dependencies [[`c9bfe8b`](https://github.com/MoonshotAI/kimi-code/commit/c9bfe8b2c8314ba4ef8806fb3b92ac654c1d1860), [`c212ae9`](https://github.com/MoonshotAI/kimi-code/commit/c212ae9715371c0d7939c15e664acbe0d7cf7fc3)]: + - @moonshot-ai/kimi-code-sdk@0.17.0 + +## 0.3.7 + +### Patch Changes + +- Updated dependencies [[`437a1b8`](https://github.com/MoonshotAI/kimi-code/commit/437a1b8ba1b7e0f6662bdadc669564fdc58c3f5a), [`0b2e803`](https://github.com/MoonshotAI/kimi-code/commit/0b2e803d5e71afaab45212bb2ee6117ecbf8bbc9), [`3c9e3b2`](https://github.com/MoonshotAI/kimi-code/commit/3c9e3b297cf5286c761159c1b4d642c478fd394d)]: + - @moonshot-ai/kimi-code-sdk@0.16.0 + ## 0.3.6 ### Patch Changes diff --git a/packages/acp-adapter/package.json b/packages/acp-adapter/package.json index 0235d35db04..ae373c656b9 100644 --- a/packages/acp-adapter/package.json +++ b/packages/acp-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/acp-adapter", - "version": "0.3.6", + "version": "0.3.8", "private": true, "description": "Agent Client Protocol adapter for kimi-code", "license": "MIT", diff --git a/packages/acp-server/test/acp-fs.test.ts b/packages/acp-server/test/acp-fs.test.ts index 1d6c75d2b82..3f7a1ec931d 100644 --- a/packages/acp-server/test/acp-fs.test.ts +++ b/packages/acp-server/test/acp-fs.test.ts @@ -51,7 +51,7 @@ describe('AcpHostFileSystem', () => { afterEach(async () => { if (tempDir !== undefined) { - await rm(tempDir, { recursive: true, force: true }); + await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); tempDir = undefined; } }); diff --git a/packages/acp-server/test/close.test.ts b/packages/acp-server/test/close.test.ts index bc79f66dfa6..21a2bde25e1 100644 --- a/packages/acp-server/test/close.test.ts +++ b/packages/acp-server/test/close.test.ts @@ -16,7 +16,7 @@ describe('acp-server session/close', () => { client = undefined; } if (homeDir !== undefined) { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); homeDir = undefined; } }); diff --git a/packages/acp-server/test/config.test.ts b/packages/acp-server/test/config.test.ts index fa0d690dff0..2b6e14e84ca 100644 --- a/packages/acp-server/test/config.test.ts +++ b/packages/acp-server/test/config.test.ts @@ -35,7 +35,7 @@ describe('acp-server config surface', () => { client = undefined; } if (homeDir !== undefined) { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); homeDir = undefined; } }); diff --git a/packages/acp-server/test/convert.test.ts b/packages/acp-server/test/convert.test.ts index 05ac5698346..fe34f16627d 100644 --- a/packages/acp-server/test/convert.test.ts +++ b/packages/acp-server/test/convert.test.ts @@ -93,7 +93,7 @@ describe('compressPromptImageParts', () => { const trash: string[] = []; afterEach(async () => { - await Promise.all(trash.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + await Promise.all(trash.splice(0).map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))); }); async function tempOriginalsDir(): Promise { diff --git a/packages/acp-server/test/e2e-turn.test.ts b/packages/acp-server/test/e2e-turn.test.ts index ec1f5686e94..b14a327e457 100644 --- a/packages/acp-server/test/e2e-turn.test.ts +++ b/packages/acp-server/test/e2e-turn.test.ts @@ -39,7 +39,7 @@ describe('acp-server real prompt turn (scripted LLM)', () => { client = undefined; } if (homeDir !== undefined) { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); homeDir = undefined; } }); @@ -562,7 +562,7 @@ describe('acp-server prompt error hygiene', () => { client = undefined; } if (homeDir !== undefined) { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); homeDir = undefined; } }); @@ -609,7 +609,7 @@ describe('acp-server builtin slash commands (local execution, no LLM turn)', () client = undefined; } if (homeDir !== undefined) { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); homeDir = undefined; } }); @@ -826,7 +826,7 @@ describe('acp-server terminal reverse-RPC (clientCapabilities.terminal)', () => client = undefined; } if (homeDir !== undefined) { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); homeDir = undefined; } }); diff --git a/packages/acp-server/test/initialize.test.ts b/packages/acp-server/test/initialize.test.ts index 9a6e82b4dbc..1254db30bd4 100644 --- a/packages/acp-server/test/initialize.test.ts +++ b/packages/acp-server/test/initialize.test.ts @@ -92,7 +92,7 @@ describe('acp-server initialize handshake', () => { toAgent.end(); toClient.end(); } finally { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }, 30_000, @@ -125,7 +125,7 @@ describe('acp-server initialize handshake', () => { toAgent.end(); toClient.end(); } finally { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }, 30_000, @@ -176,7 +176,7 @@ describe('acp-server initialize handshake', () => { toAgent.end(); toClient.end(); } finally { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }, 30_000, diff --git a/packages/acp-server/test/lifecycle.test.ts b/packages/acp-server/test/lifecycle.test.ts index edc6507c186..f6e910fd738 100644 --- a/packages/acp-server/test/lifecycle.test.ts +++ b/packages/acp-server/test/lifecycle.test.ts @@ -73,7 +73,7 @@ describe('acp-server session lifecycle', () => { client = undefined; } if (homeDir !== undefined) { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); homeDir = undefined; } }); diff --git a/packages/acp-server/test/skills.test.ts b/packages/acp-server/test/skills.test.ts index 3b26cd1b0df..0d89d350e52 100644 --- a/packages/acp-server/test/skills.test.ts +++ b/packages/acp-server/test/skills.test.ts @@ -84,7 +84,7 @@ describe('acp-server skills / available commands', () => { client = undefined; } if (homeDir !== undefined) { - await rm(homeDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); homeDir = undefined; } }); diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index edb4c14c5a7..8d370cde789 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -4,7 +4,7 @@ ## Scopes -Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (string-valued, declared in `src/app/scopes.ts` — the DI kernel in `src/_base/di/scope.ts` only knows opaque `ScopeKind` strings plus the order installed by `setScopeTopology`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close/delete) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …), projected by five seed-adapter units (`src/session/sessionSeed/sessionSeedAdapters.ts`): each adapter `@ref`-observes its workspace upstream, live-reads through getters, re-fires `onDidChange` when the backing generation switches, and provides the seed token synchronously through the session scope's `ScopeOptions.assemble` hook before session services activate (a host without the workspace layer keeps the scope's default `extra` registration; the inline seeds stay plain `extra`). `workspaceMcp` is pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` (mcp.json files + plugin contributions, fs-watch refreshed), and MCP persistence — the `[mcp]` config section plus OAuth credentials — lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong. `workspaceDirs` is backed by `.kimi-code/local.toml`; `workspaceToolPolicy` is the os-level tool veto. A session created with `CreateSessionOptions.mcpServers` additionally gets ephemeral per-session MCP servers: `workspaceMcp.sessionOverlay` builds a session-owned manager for them (never persisted, invisible to the handler's other sessions, not gated by `workspaceTrust`), the session's `ISessionMcpHandle` seed carries a `session/mcp` `MergedMcpConnectionView` over the shared manager and the overlay (an ephemeral name shadows a workspace server for that session), and `sessionLifecycle` shuts the overlay down when the session handle disposes (backstopped by the lifecycle service's own dispose for teardown paths that bypass the handle wrapper). Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) contribute `AgentProfileContribution` records to the collection via `this.provide`, tagged with the handler's `workspaceId`; the App-scope `IAgentProfileRegistry` is a fold over that collection (same-(sourceId, workspaceKey) later records shadow earlier ones, provider death withdraws; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles through an owned helper unit), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged read view directly (name-level dedup + the builtin-override rule in the projection) — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). The trust state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes. The old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. +Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (string-valued, declared in `src/app/scopes.ts` — the DI kernel in `src/_base/di/scope.ts` only knows opaque `ScopeKind` strings plus the order installed by `setScopeTopology`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close/delete) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …), projected by five seed-adapter units (`src/session/sessionSeed/sessionSeedAdapters.ts`): each adapter `@ref`-observes its workspace upstream, live-reads through getters, re-fires `onDidChange` when the backing generation switches, and provides the seed token synchronously through the session scope's `ScopeOptions.configureContainer` hook before session services activate (a host without the workspace layer keeps the scope's default `extra` registration; the inline seeds stay plain `extra`). The same `configureContainer` window also fires `sessionLifecycle.onWillCreateSession` — a synchronous participation event whose surface speaks the session domain's own vocabulary (`readSeed` / `contributeSeed` / `onSessionDispose`), so Workspace-scope participants contribute session-scoped resources without the lifecycle depending on them or on kernel mechanics: `workspaceMcp` uses it to activate a session's ephemeral-server overlay (the configs travel as the `ISessionEphemeralMcpServers` session seed), contributing the merged `ISessionMcpHandle` over the adapter's workspace projection and attaching the overlay's shutdown to the session's teardown. `workspaceMcp` is pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` (mcp.json files + plugin contributions, fs-watch refreshed), and MCP persistence — the `[mcp]` config section plus OAuth credentials — lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong. `workspaceDirs` is backed by `.kimi-code/local.toml`; `workspaceToolPolicy` is the os-level tool veto. A session created with `CreateSessionOptions.mcpServers` additionally gets ephemeral per-session MCP servers: `workspaceMcp.sessionOverlay` builds a session-owned manager for them (never persisted, invisible to the handler's other sessions, not gated by `workspaceTrust`), the session's `ISessionMcpHandle` seed carries a `session/mcp` `MergedMcpConnectionView` over the shared manager and the overlay (an ephemeral name shadows a workspace server for that session), and `sessionLifecycle` shuts the overlay down when the session handle disposes (backstopped by the lifecycle service's own dispose for teardown paths that bypass the handle wrapper). Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) contribute `AgentProfileContribution` records to the collection via `this.provide`, tagged with the handler's `workspaceId`; the App-scope `IAgentProfileRegistry` is a fold over that collection (same-(sourceId, workspaceKey) later records shadow earlier ones, provider death withdraws; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles through an owned helper unit), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged read view directly (name-level dedup + the builtin-override rule in the projection) — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). The trust state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes. The old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. ## Units and contribution points (L3) @@ -13,13 +13,13 @@ The DI kernel (`src/_base/di/`) owns the unit layer on top of the scoped registr - `service.ts` — `Service`: the unit base class (extends `Disposable`). Capabilities live on `this` (`provide` / `effect` / `on` / `get` / `ref`, plus `name` / `state` / `config`). Two-phase construction: inside the ctor `provide`/`on`/`effect` buffer (writes only — `get`/`ref` throw, dependencies are constructor parameters); the kernel binds the runtime after `Reflect.construct` and flushes in writing order; a manually `new`ed instance throws on every capability call. Services whose own members collide with the `Service` vocabulary keep `extends Disposable` with a NOTE comment — still full DI units (cascade/ledger do not require `Service`). - `fiber.ts` — the `Fiber` capability interface (not a DI token), `FiberHandle` (thenable / `state` / `uid` / `update` / `dispose`), `ServiceRecipe` (class / arrow function / `{apply}`), the `FiberState` five-state machine, and `ScopeUnits(kind)` — the materialization collection token, one per scope kind. - `collection.ts` — `collection(name)` contribution tokens. Contribute with `this.provide(token, value)`; a fold declares the token as a constructor parameter and receives a `CollectionView` (`items` / `records` / incremental `onDidChange`). Records are visible to the provider's ancestors and descendants (never sibling subtrees); provider death withdraws. Collection edges enter the graph for introspection but never join a cascade contagion set. -- `scopeUnits.ts` — the kernel fold: every scope-creation point (`createScopedChildHandle` / `Scope.createApp` / `Scope.createChild`) runs `watchScopeUnits(container, kind)` before eager activation, materializing each visible `ScopeUnits(kind)` record's recipe as a unit inside the new scope (disposal hangs on the record provider's book — provider death tears the materialized units down across the tree). `ScopeOptions.assemble` runs at the same point (the session seed adapters use it). +- `scopeUnits.ts` — the kernel fold: every scope-creation point (`createScopedChildHandle` / `Scope.createApp` / `Scope.createChild`) runs `watchScopeUnits(container, kind)` before eager activation, materializing each visible `ScopeUnits(kind)` record's recipe as a unit inside the new scope (disposal hangs on the record provider's book — provider death tears the materialized units down across the tree). `ScopeOptions.configureContainer` runs at the same point (the session seed adapters use it). - `instantiation.ts` — the `@ref(IX)` decorator factory (`LiveRef`: `current` live read + `onDidChange` availability event; observation creates no binding and no graph edge) and `ScopeActivation`. - `src/app/feature/` — `IFeatureManager` (App scope): runtime unit assembly (`provideUnit` / `unprovideUnit` / `updateUnit`) and introspection (`units()` / `onDidChangeUnits`); managed units hang on the manager's own book. External package management stays with `IPluginService`. The `features` assembly (`src/features/featureAssemblyService.ts`) drains the module-level feature table through it. -The four contribution seams (token → fold): config sections — `ConfigSectionContribution` → `ConfigRegistry` fold (`src/app/config/`; module-level `registerConfigSection` stays the static built-in channel drained at construction, a withdrawn runtime record unregisters the section while user TOML values survive); agent tools — `AgentToolContribution` → `AgentToolActivationService` fold (built-in records provided once at App scope by `builtinToolAssemblyService`; `registerAgentToolService` stays the static channel: Agent-scope DI `OnDemand` registration + module table); agent profiles — `AgentProfileContribution` → `IAgentProfileRegistry` fold (see Scopes); wire vocabulary — `WireModelContribution` → `WireService` fold (a record bundles `models` / `ops` / `crossReducers` / `checkpointedModels`; the built-in layer is the module tables drained at fold time — `defineOp` / `defineModel` / `defineCheckpointedModel` stay the static channel — and replaying a withdrawn domain's history lands on the unknown-op skip-and-count path). A fifth seam: executable commands — `CommandContribution` → `IAgentCommandService` fold (`src/agent/command/`; a contributed command runs engine-side — `run(ctx)` gets `ctx.get` resolving through the agent container, valid only during the synchronous part of `run`; name-level dedup, last record wins; surfaced over RPC as `agentRPCService.listCommands` / `runCommand`). +The four contribution seams (token → fold): config sections — `ConfigSectionContribution` → `ConfigRegistry` fold (`src/app/config/`; module-level `registerConfigSection` stays the static built-in channel drained at construction, a withdrawn runtime record unregisters the section while user TOML values survive); agent tools — `AgentToolContribution` → `AgentToolActivationService` fold (built-in records provided once at App scope by `builtinToolAssemblyService`; `registerAgentToolService` stays the static channel: Agent-scope DI `OnDemand` registration + module table); agent profiles — `AgentProfileContribution` → `IAgentProfileRegistry` fold (see Scopes); wire vocabulary — `WireModelContribution` → `WireService` fold (a record bundles `models` / `ops` / `crossReducers` / `checkpointedModels`; the built-in layer is the module tables drained at fold time — `defineOp` / `defineModel` / `defineCheckpointedModel` stay the static channel — and replaying a withdrawn domain's history lands on the unknown-op skip-and-count path). A fifth seam: executable commands — `CommandContribution` → `IAgentCommandService` fold (`src/agent/command/`; a contributed command runs engine-side — `run(ctx)` gets `ctx.get` resolving through the agent container, valid only during the synchronous part of `run`; name-level dedup, last record wins; surfaced over RPC as `agentCommandService.list` / `run`). -`src/features/` — built-in capabilities authored as self-contained Feature units (`plan` is the first, extracted from `agent/plan` + `agent/tools/plan`). A `Feature` (`src/features/feature.ts`) is an App-scope unit recipe with a `static override readonly name` and `contribute*` helpers composing the seams: `contributeService(scope, id, ctor)` / `contributeAgentService` (per-scope materialization via `ScopeUnits` — provider death retracts everywhere, 连坐), `contributeTool` (per-agent `OnDemand` registration + the `AgentToolContribution` record), `contributeProfiles`, `contributeConfig`, `contributeCommand`, plus `onDispose`. Feature modules self-register at import (`registerFeature`, `src/features/featureRegistry.ts`); the App-scope `IFeatureAssemblyService` drains the table through `IFeatureManager.provideUnit`, so every feature is a named, introspectable, retractable managed unit. Built-in features keep user-facing static contracts — config sections, agent profiles, wire vocabulary — on the static import=register channels (the config/state manifest generators read static tables / call sites; wire records must stay replayable); the Feature unit carries the runtime capabilities (services, tools, commands). The string form of the unit `on(...)` capability (`this.on('turn.ended', …)`) is backed by the production `FiberEventResolver` registered in `src/app/event/fiberEventResolver.ts`, resolving against the scope's `IEventBus`. +`src/features/` — built-in capabilities authored as self-contained Feature units (`plan` was the first, extracted from `agent/plan` + `agent/tools/plan`; `swarm` followed, extracted from `agent/swarm` + `session/swarm` + `agent/tools/agent-swarm` into a scope-organized `agent/` + `session/` + `tools/` layout). A `Feature` (`src/features/feature.ts`) is an App-scope unit recipe with a `static override readonly name` and `contribute*` helpers composing the seams: `contributeService(scope, id, ctor)` / `contributeAgentService` (per-scope materialization via `ScopeUnits` — provider death retracts everywhere, 连坐), `contributeTool` (per-agent `OnDemand` registration + the `AgentToolContribution` record), `contributeProfiles`, `contributeConfig`, `contributeCommand`, plus `onDispose`. Feature modules self-register at import (`registerFeature`, `src/features/featureRegistry.ts`); the App-scope `IFeatureAssemblyService` drains the table through `IFeatureManager.provideUnit`, so every feature is a named, introspectable, retractable managed unit. Built-in features keep user-facing static contracts — config sections, agent profiles, wire vocabulary — on the static import=register channels (the config/state manifest generators read static tables / call sites; wire records must stay replayable); the Feature unit carries the runtime capabilities (services, tools, commands). The string form of the unit `on(...)` capability (`this.on('turn.ended', …)`) is backed by the production `FiberEventResolver` registered in `src/app/event/fiberEventResolver.ts`, resolving against the scope's `IEventBus`. ## Ledger and cascade (L0/L2) @@ -86,6 +86,10 @@ Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / at `context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint reducers, and the transcript reducer) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models. +## Model-facing reminders + +Two delivery paths only — never introduce a third (no deferred-delivery queues, no mid-step splice channels): reminders that restate current state (goal state, plan mode, date change, …) register a `contextInjector` provider (`register`) that reconciles at every step head (before the step's request is built) and re-emits after compaction or undo; reminders that report a one-off event (goal cancelled, AGENTS.md discovered, `/init` finished, …) append at the event point through `IAgentSystemReminderService.appendSystemReminder` with origin `{ kind: 'injection', variant: '' }`, where the event point must itself be a safe position (a step/restore hook, an idle moment, or the loop-event fold's deferred append). `kind: 'injection'` is a lifecycle classification (hidden from the UI, not an undo anchor, dropped by compaction), not a provenance claim; prompt-owned attachments additionally carry `ownerPromptId` so undo treats them as part of their host prompt. + ## Docs Per-domain references live in `docs/`. diff --git a/packages/agent-core-v2/CHANGELOG.md b/packages/agent-core-v2/CHANGELOG.md index 94e48650da8..bb6734cb8ff 100644 --- a/packages/agent-core-v2/CHANGELOG.md +++ b/packages/agent-core-v2/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/agent-core-v2 +## 0.3.2 + +### Patch Changes + +- [#2815](https://github.com/MoonshotAI/kimi-code/pull/2815) [`43c68f5`](https://github.com/MoonshotAI/kimi-code/commit/43c68f58f578c88d9f503afb72f12d343c2aa5c7) Thanks [@liruifengv](https://github.com/liruifengv)! - Keep session updatedAt stable across metadata management writes: rename and archive/restore no longer bump it, fork inherits the source session's recency, and agent registration is non-touching; add SessionMeta.archivedAt (set on archive, cleared on restore) and surface it as archived_at through the session index and the v1/v2 session routes. + ## 0.3.1 ### Patch Changes diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index e341135e54d..4cbc0aa7a48 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,7 +8,7 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (26 sections · 3 overlay(s)) +# Index (26 sections · 2 overlay(s)) # background src/agent/task/configSection.ts # builtinProductSkills src/app/skillCatalog/configSection.ts # cron src/app/cron/configSection.ts @@ -28,7 +28,7 @@ # models src/app/kosongConfig/configSection.ts # permission src/agent/permissionRules/configSection.ts # providers src/app/kosongConfig/configSection.ts -# secondaryModel src/app/kosongConfig/configSection.ts +# secondaryModel src/session/subagent/configSection.ts # services src/app/auth/configSection.ts # subagent src/session/subagent/configSection.ts # task src/agent/task/configSection.ts @@ -37,7 +37,6 @@ # tools src/agent/toolPolicy/configSection.ts # (overlay) servicesCredentialEnvOverlay src/app/auth/configSection.ts # (overlay) kimiModelEnvOverlay src/app/kosongConfig/envOverlay.ts -# (overlay) secondaryModelOverlay src/app/kosongConfig/secondaryModelOverlay.ts # ########################################################################## # background @@ -331,15 +330,15 @@ merge_all_available_skills = true # ########################################################################## # secondaryModel (config.toml: secondary_model) -# owner: src/app/kosongConfig/configSection.ts +# owner: src/session/subagent/configSection.ts # scope: core -# hooks: stripEnv -# env: -# model <- KIMI_SECONDARY_MODEL (custom parse) -# default_effort <- KIMI_SECONDARY_EFFORT (custom parse) # ########################################################################## [secondary_model] +# default_model: string +# models: record +# force: boolean +# model: string # max_context_size: integer # max_input_size: integer # max_output_size: integer @@ -350,7 +349,6 @@ merge_all_available_skills = true # support_efforts: string[] # default_effort: string # off_effort: string -# model: string # ########################################################################## # services diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index bdc97955193..c8924cd53cb 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -23,7 +23,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: 70 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 69 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -57,12 +57,12 @@ // activityView.lastTurn src/agent/activityView/activityViewService.ts // activityView.lifecycle src/agent/activityView/activityViewService.ts // activityView.turn src/agent/activityView/activityViewService.ts +// agentPlugin.sessionStartRefreshPending src/agent/plugin/agentPluginService.ts // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts -// contextInjector.isNewTurn src/agent/contextInjector/contextInjectorService.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts -// dateChange.seed src/agent/dateChange/dateChangeService.ts +// dateChange.seed src/features/dateChange/dateChangeService.ts // externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts // fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts @@ -118,7 +118,6 @@ // toolDedupe.syntheticCallIds src/agent/toolDedupe/toolDedupeService.ts // toolExecutor.dupTypeTurnId src/agent/toolExecutor/toolExecutorService.ts // toolExecutor.toolCallDupTypes src/agent/toolExecutor/toolExecutorService.ts -// toolSelect.needsBoundaryInjection src/agent/toolSelect/toolSelectAnnouncementsService.ts // toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts // usage.currentTurn src/agent/usage/usageService.ts // usage.currentTurnId src/agent/usage/usageService.ts @@ -449,11 +448,12 @@ export interface SessionStateSnapshot { readonly id: string; readonly version?: number; readonly title?: string; - readonly isCustomTitle?: boolean; + readonly titleKind?: 'replaceable' | 'generated' | 'custom'; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly cwd?: string; readonly forkedFrom?: string; readonly agents?: Readonly; 'agentsMdReminder.seeded': boolean; - // src/agent/contextInjector/contextInjectorService.ts - 'contextInjector.isNewTurn': boolean; // src/agent/contextProjector/contextProjectorService.ts 'contextProjector.lastRepairSignature': string | null; - // src/agent/dateChange/dateChangeService.ts - 'dateChange.seed': /* DateDisclosure — packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts */ { - readonly localDate: string; - readonly timeZone: string; - readonly renderGeneration: number; - } | undefined; // src/agent/externalHooks/externalHooksService.ts 'externalHooks.stopHookContinuationUsed': boolean; // src/agent/fullCompaction/fullCompactionService.ts @@ -1125,6 +1102,8 @@ export interface AgentStateSnapshot { }>; // src/agent/permissionMode/injection/permissionModeInjection.ts 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; + // src/agent/plugin/agentPluginService.ts + 'agentPlugin.sessionStartRefreshPending': boolean; // src/agent/profile/profileService.ts 'profile.activeToolNamesOverlay': readonly string[] | undefined; 'profile.agentsMdWarning': string | undefined; @@ -1197,8 +1176,6 @@ export interface AgentStateSnapshot { // src/agent/toolExecutor/toolExecutorService.ts 'toolExecutor.dupTypeTurnId': number | undefined; 'toolExecutor.toolCallDupTypes': Map; - // src/agent/toolSelect/toolSelectAnnouncementsService.ts - 'toolSelect.needsBoundaryInjection': boolean; // src/agent/toolSelect/toolSelectService.ts 'toolSelect.pendingLoaded': Set; // src/agent/usage/usageService.ts @@ -1209,6 +1186,12 @@ export interface AgentStateSnapshot { inputCacheCreation: number; } | undefined; 'usage.currentTurnId': number | undefined; + // src/features/dateChange/dateChangeService.ts + 'dateChange.seed': /* DateDisclosure — packages/agent-core-v2/src/features/dateChange/dateChangeService.ts */ { + readonly localDate: string; + readonly timeZone: string; + readonly renderGeneration: number; + } | undefined; // src/features/plan/injection/planModeInjection.ts 'plan.wasActive': boolean; } diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index ee6929b2192..1a6b74994d3 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,57 +21,58 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (50 record types) -// config.update profile persisted src/agent/profile/profileOps.ts -// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts -// cron.add cron transient src/session/cron/cronOps.ts -// cron.cursor cron transient src/session/cron/cronOps.ts -// cron.delete cron transient src/session/cron/cronOps.ts -// forked goal persisted src/agent/goal/goalOps.ts -// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// goal.clear goal persisted src/agent/goal/goalOps.ts -// goal.create goal persisted src/agent/goal/goalOps.ts -// goal.update goal persisted src/agent/goal/goalOps.ts -// interaction.request interaction persisted src/session/interaction/interactionOps.ts -// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts -// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts -// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts -// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts -// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts -// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan persisted src/features/plan/planOps.ts -// plan_mode.enter plan persisted src/features/plan/planOps.ts -// plan_mode.exit plan persisted src/features/plan/planOps.ts -// plan.revision plan persisted src/features/plan/planOps.ts -// profile.bind profile persisted src/agent/profile/profileOps.ts -// skill.activate skill transient src/agent/skill/skillOps.ts -// supermoon_mode.enter supermoon persisted src/agent/supermoon/supermoonOps.ts -// supermoon_mode.exit supermoon persisted src/agent/supermoon/supermoonOps.ts -// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts -// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts -// task.started task persisted src/agent/task/taskOps.ts -// task.terminated task persisted src/agent/task/taskOps.ts -// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.update_store todo persisted src/session/todo/todoOps.ts -// turn.cancel turn persisted src/agent/loop/turnOps.ts -// turn.ended turn persisted src/agent/loop/turnOps.ts -// turn.prompt turn persisted src/agent/loop/turnOps.ts -// turn.steer turn persisted src/agent/loop/turnOps.ts -// usage.record usage persisted src/agent/usage/usageOps.ts +// Index (51 record types) +// config.update profile persisted src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts +// cron.add cron transient src/session/cron/cronOps.ts +// cron.cursor cron transient src/session/cron/cronOps.ts +// cron.delete cron transient src/session/cron/cronOps.ts +// forked goal persisted src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// goal.clear goal persisted src/agent/goal/goalOps.ts +// goal.create goal persisted src/agent/goal/goalOps.ts +// goal.update goal persisted src/agent/goal/goalOps.ts +// interaction.request interaction persisted src/session/interaction/interactionOps.ts +// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts +// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts +// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan persisted src/features/plan/planOps.ts +// plan_mode.enter plan persisted src/features/plan/planOps.ts +// plan_mode.exit plan persisted src/features/plan/planOps.ts +// plan.revision plan persisted src/features/plan/planOps.ts +// plugin.session_start pluginSessionStartSnapshot persisted src/agent/plugin/agentPluginOps.ts +// profile.bind profile persisted src/agent/profile/profileOps.ts +// skill.activate skill transient src/agent/skill/skillOps.ts +// supermoon_mode.enter supermoon persisted src/agent/supermoon/supermoonOps.ts +// supermoon_mode.exit supermoon persisted src/agent/supermoon/supermoonOps.ts +// swarm_mode.enter swarm persisted src/features/swarm/swarmOps.ts +// swarm_mode.exit swarm persisted src/features/swarm/swarmOps.ts +// task.started task persisted src/agent/task/taskOps.ts +// task.terminated task persisted src/agent/task/taskOps.ts +// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.update_store todo persisted src/session/todo/todoOps.ts +// turn.cancel turn persisted src/agent/loop/turnOps.ts +// turn.ended turn persisted src/agent/loop/turnOps.ts +// turn.prompt turn persisted src/agent/loop/turnOps.ts +// turn.steer turn persisted src/agent/loop/turnOps.ts +// usage.record usage persisted src/agent/usage/usageOps.ts /** * model: profile · persisted @@ -450,6 +451,15 @@ interface PlanRevisionPayload { bytes: number; } +/** + * model: pluginSessionStartSnapshot · persisted + * owner: src/agent/plugin/agentPluginOps.ts + */ +interface PluginSessionStartPayload { + _name: 'plugin.session_start'; + content: string | null; +} + /** * model: profile · persisted · cross-reducers: profile.activeTools * owner: src/agent/profile/profileOps.ts @@ -512,7 +522,7 @@ interface SupermoonModeExitPayload { /** * model: swarm · persisted · toEvent - * owner: src/agent/swarm/swarmOps.ts + * owner: src/features/swarm/swarmOps.ts */ interface SwarmModeEnterPayload { _name: 'swarm_mode.enter'; @@ -522,7 +532,7 @@ interface SwarmModeEnterPayload { /** * model: swarm · persisted · toEvent · cross-reducers: contextMemory - * owner: src/agent/swarm/swarmOps.ts + * owner: src/features/swarm/swarmOps.ts */ interface SwarmModeExitPayload { _name: 'swarm_mode.exit'; @@ -630,7 +640,7 @@ interface ToolsUpdateStorePayload { } /** - * model: turn · persisted · cross-reducers: interruptionReminder + * model: turn · persisted * owner: src/agent/loop/turnOps.ts */ interface TurnCancelPayload { @@ -766,6 +776,7 @@ interface WirePayloadMap { "plan_mode.enter": PlanModeEnterPayload; "plan_mode.exit": PlanModeExitPayload; "plan.revision": PlanRevisionPayload; + "plugin.session_start": PluginSessionStartPayload; "profile.bind": ProfileBindPayload; "skill.activate": SkillActivatePayload; "supermoon_mode.enter": SupermoonModeEnterPayload; diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index cd1af0f44b5..716f008d39d 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/agent-core-v2", - "version": "0.3.1", + "version": "0.3.2", "private": true, "description": "The unified agent engine for Kimi (v2 — DI Scope architecture)", "license": "MIT", diff --git a/packages/agent-core-v2/src/_base/di/collection.ts b/packages/agent-core-v2/src/_base/di/collection.ts index 4f69dedbba9..75a96e6509e 100644 --- a/packages/agent-core-v2/src/_base/di/collection.ts +++ b/packages/agent-core-v2/src/_base/di/collection.ts @@ -36,8 +36,15 @@ export interface CollectionToken { const _collectionTokens = new Map>(); const _collectionTokenSet = new WeakSet(); +const _collectionValidators = new WeakMap< + object, + (value: unknown, existing: readonly unknown[]) => void +>(); -export function collection(name: string): CollectionToken { +export function collection( + name: string, + options: { readonly validate?: (value: T, existing: readonly T[]) => void } = {}, +): CollectionToken { const existing = _collectionTokens.get(name); if (existing !== undefined) { return existing as CollectionToken; @@ -61,6 +68,12 @@ export function collection(name: string): CollectionToken { Object.defineProperty(token, 'name', { value: name, enumerable: false, configurable: true }); _collectionTokens.set(name, token as CollectionToken); _collectionTokenSet.add(token); + if (options.validate !== undefined) { + _collectionValidators.set( + token, + options.validate as (value: unknown, existing: readonly unknown[]) => void, + ); + } return token; } @@ -119,6 +132,10 @@ export class CollectionStore { records = new Map(); this._records.set(token as CollectionToken, records); } + _collectionValidators.get(token)?.( + value, + [...records.values()].map((entry) => entry.value), + ); const record: StoredRecord = { id: ++this._nextId, value, diff --git a/packages/agent-core-v2/src/_base/di/lifecycle.ts b/packages/agent-core-v2/src/_base/di/lifecycle.ts index 5ae86e22e3c..4cb5dc5d147 100644 --- a/packages/agent-core-v2/src/_base/di/lifecycle.ts +++ b/packages/agent-core-v2/src/_base/di/lifecycle.ts @@ -5,7 +5,15 @@ import { onUnexpectedError } from '../errors/unexpectedError'; import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; +export interface IDisposableDebugLabel { + readonly debugLabel?: string; +} + function disposableLabel(d: IDisposable): string { + const debugLabel = (d as IDisposableDebugLabel).debugLabel; + if (typeof debugLabel === 'string' && debugLabel.length > 0) { + return debugLabel; + } return `disposable:${d.constructor?.name ?? 'anonymous'}`; } diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index 6d4d7272f3e..ced7a35f999 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -84,8 +84,8 @@ export type ScopeSeed = ReadonlyArray< export interface ScopeOptions { readonly id?: string; - readonly extra?: ScopeSeed; - readonly assemble?: (container: InstantiationService) => void; + readonly seeds?: ScopeSeed; + readonly configureContainer?: (container: InstantiationService) => void; } export interface IScopeHandle { @@ -100,10 +100,10 @@ export type IWorkspaceScopeHandle = IScopeHandle<'workspace'>; export type ISessionScopeHandle = IScopeHandle<'session'>; export type IAgentScopeHandle = IScopeHandle<'agent'>; -function buildCollection(extra?: ScopeSeed): ServiceCollection { +function buildCollection(seeds?: ScopeSeed): ServiceCollection { const collection = new ServiceCollection(); - if (extra) { - for (const [id, value] of extra) { + if (seeds) { + for (const [id, value] of seeds) { collection.set(id, value); } } @@ -137,12 +137,12 @@ export function createScopedChildHandle( id: string, options: ScopeOptions = {}, ): IScopeHandle { - const collection = buildCollection(options.extra); + const collection = buildCollection(options.seeds); const child = parent.createChild(collection); (child as InstantiationService).debugLabel = id; try { watchScopeUnits(child as InstantiationService, kind); - options.assemble?.(child as InstantiationService); + options.configureContainer?.(child as InstantiationService); provideScopeServices(child, kind, collection); } catch (error) { child.dispose(); @@ -189,12 +189,12 @@ export class Scope implements IDisposable { static createApp(options: ScopeOptions = {}): Scope { const kind: ScopeKind = 'app'; - const collection = buildCollection(options.extra); + const collection = buildCollection(options.seeds); const instantiation = new InstantiationService(collection, true); instantiation.debugLabel = options.id ?? 'app'; try { watchScopeUnits(instantiation, kind); - options.assemble?.(instantiation); + options.configureContainer?.(instantiation); provideScopeServices(instantiation, kind, collection); } catch (error) { instantiation.dispose(); @@ -223,12 +223,12 @@ export class Scope implements IDisposable { if (this.children.has(id)) { throw new Error(`Scope '${this.id}' already has a child with id '${id}'`); } - const collection = buildCollection(options.extra); + const collection = buildCollection(options.seeds); const childInstantiation = this.instantiation.createChild(collection); (childInstantiation as InstantiationService).debugLabel = id; try { watchScopeUnits(childInstantiation as InstantiationService, kind); - options.assemble?.(childInstantiation as InstantiationService); + options.configureContainer?.(childInstantiation as InstantiationService); provideScopeServices(childInstantiation, kind, collection); } catch (error) { childInstantiation.dispose(); diff --git a/packages/agent-core-v2/src/_base/di/test.ts b/packages/agent-core-v2/src/_base/di/test.ts index 11b332889d4..d861e71151a 100644 --- a/packages/agent-core-v2/src/_base/di/test.ts +++ b/packages/agent-core-v2/src/_base/di/test.ts @@ -23,14 +23,14 @@ export interface ScopedTestHost { } export function createScopedTestHost(appStubs: ScopeSeed = []): ScopedTestHost { - const app = createAppScope({ extra: appStubs }); + const app = createAppScope({ seeds: appStubs }); return { app, child(kind, id, stubs = []) { - return app.createChild(kind, id, { extra: stubs }); + return app.createChild(kind, id, { seeds: stubs }); }, childOf(parent, kind, id, stubs = []) { - return parent.createChild(kind, id, { extra: stubs }); + return parent.createChild(kind, id, { seeds: stubs }); }, dispose() { app.dispose(); diff --git a/packages/agent-core-v2/src/_base/event.ts b/packages/agent-core-v2/src/_base/event.ts index 802fe467416..35b0559ccd0 100644 --- a/packages/agent-core-v2/src/_base/event.ts +++ b/packages/agent-core-v2/src/_base/event.ts @@ -4,7 +4,9 @@ * `onWill` events whose listeners register work via `waitUntil`), the * `handleVetos` helper (for `onBefore*` veto events whose listeners answer * with `veto(value, id)`), and event combinators (`once` / `map` / `filter` - * / `any`). + * / `any`). `Emitter` accepts an optional debug name that its + * `EventSubscription` carries as an `on:` ledger label, so event + * subscriptions stay identifiable in unit-book introspection. */ import { onUnexpectedError, safelyCallListener } from './errors/unexpectedError'; @@ -13,6 +15,7 @@ import { DisposableStore, combinedDisposable, type IDisposable, + type IDisposableDebugLabel, } from './di/lifecycle'; import { LinkedList } from './di/util/linkedList'; @@ -29,11 +32,31 @@ interface ListenerEntry { thisArg: unknown; } +export class EventSubscription implements IDisposable, IDisposableDebugLabel { + readonly debugLabel: string | undefined; + private _removed = false; + + constructor( + debugName: string | undefined, + private readonly _remove: () => void, + ) { + this.debugLabel = debugName === undefined ? undefined : `on:${debugName}`; + } + + dispose(): void { + if (this._removed) return; + this._removed = true; + this._remove(); + } +} + export class Emitter { protected _listeners: Set> | undefined; private _disposed = false; private _event: Event | undefined; + constructor(public readonly debugName?: string) {} + get event(): Event { this._event ??= (listener, thisArg, disposables) => { if (this._disposed) { @@ -43,17 +66,12 @@ export class Emitter { const entry: ListenerEntry = { listener, thisArg }; this._listeners.add(entry); - let removed = false; - const subscription: IDisposable = { - dispose: () => { - if (removed) return; - removed = true; - if (this._disposed) { - return; - } - this._listeners?.delete(entry); - }, - }; + const subscription = new EventSubscription(this.debugName, () => { + if (this._disposed) { + return; + } + this._listeners?.delete(entry); + }); if (disposables !== undefined) { if (disposables instanceof DisposableStore) { @@ -67,6 +85,10 @@ export class Emitter { return this._event; } + get listenerCount(): number { + return this._listeners?.size ?? 0; + } + fire(value: T): void { if (this._disposed || this._listeners === undefined) { return; diff --git a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts index 3e4d03d9784..a9c4e5ffc8e 100644 --- a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts +++ b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts @@ -7,9 +7,12 @@ * same suite runs identically on any host OS. `probeHostEnvironmentFromNode()` * bundles the Node defaults for production callers and memoises the promise. * - * On Windows the probe expects bash from Git for Windows or MSYS2. If it - * cannot be located the function throws a plain `Error` with the checked paths - * in the message. Set `KIMI_SHELL_PATH` to override. + * On Windows the probe expects bash from Git for Windows or MSYS2. If no + * shell can be located the function throws `ProbeShellNotFoundError`, a + * distinct type carrying the checked paths (`checked`) with an install hint + * in its message, so the DI boundary can tell a missing shell apart from + * other probe errors and translate it into a coded error. Set + * `KIMI_SHELL_PATH` to override. * * Kept as a pure helper with no DI dependencies. */ @@ -24,6 +27,16 @@ export type OsKind = string; export type ShellName = 'bash' | 'sh'; export type PathClass = 'posix' | 'win32'; +export class ProbeShellNotFoundError extends Error { + readonly checked: readonly string[]; + + constructor(message: string, checked: readonly string[]) { + super(message); + this.name = 'ProbeShellNotFoundError'; + this.checked = checked; + } +} + export interface HostEnvironmentInfo { readonly osKind: OsKind; readonly osArch: string; @@ -181,8 +194,9 @@ async function locateWindowsGitBash(deps: HostEnvironmentProbeDeps): Promise(key: StateKey): void; + register(key: StateKey): IDisposable; has(key: StateKey): boolean; get(key: StateKey): T; set(key: StateKey, value: T): void; @@ -69,6 +69,7 @@ export interface IStateRegistry { // NOTE: stays Disposable — its own 'get' collides with the Fiber export class StateRegistry extends Disposable implements IStateRegistry { private readonly values = new Map(); + private readonly registrations = new Map(); private readonly keyEmitters = new Map>(); private readonly anyEmitter = this._register(new Emitter()); readonly onDidChangeAny: Event = this.anyEmitter.event; @@ -76,11 +77,20 @@ export class StateRegistry extends Disposable implements IStateRegistry { protected readonly inspectScope: string = 'unknown'; protected inspectParent?: IStateRegistry; - register(key: StateKey): void { + register(key: StateKey): IDisposable { if (this.values.has(key.name)) { throw new BugIndicatingError(`state key '${key.name}' is already registered`); } + const registration = {}; + this.registrations.set(key.name, registration); this.values.set(key.name, key.initial()); + return toDisposable(() => { + if (this.registrations.get(key.name) !== registration) return; + this.registrations.delete(key.name); + this.values.delete(key.name); + this.keyEmitters.get(key.name)?.dispose(); + this.keyEmitters.delete(key.name); + }); } has(key: StateKey): boolean { diff --git a/packages/agent-core-v2/src/_base/utils/paths.ts b/packages/agent-core-v2/src/_base/utils/paths.ts index e6b230df739..34de452b174 100644 --- a/packages/agent-core-v2/src/_base/utils/paths.ts +++ b/packages/agent-core-v2/src/_base/utils/paths.ts @@ -1,14 +1,40 @@ /** - * `_base/utils/paths` (cross-cutting) — pure path-filter predicates. + * `_base/utils/paths` (cross-cutting) — pure path predicates and directory + * walks. * * Constrains filesystem watches to selected subtrees and scanner-visible - * entries. + * entries, and walks host directory chains with platform-native path + * semantics so drive-letter / UNC roots keep their host form. */ +import nodePath from 'node:path'; + function normalizeSlashes(p: string): string { return p.replaceAll('\\', '/'); } +export interface UpwardRootPathApi { + resolve(dir: string): string; + dirname(dir: string): string; + join(...segments: string[]): string; +} + +export async function findUpwardRoot( + workDir: string, + markerName: string, + hasMarker: (markerPath: string) => Promise, + pathApi: UpwardRootPathApi = nodePath, +): Promise { + const start = pathApi.resolve(workDir); + let current = start; + while (true) { + if (await hasMarker(pathApi.join(current, markerName))) return normalizeSlashes(current); + const parent = pathApi.dirname(current); + if (parent === current) return normalizeSlashes(start); + current = parent; + } +} + export interface SubtreeWatchFilterOptions { readonly maxDepth?: number; readonly skipEntry?: (entryName: string) => boolean; diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 0076f856020..61a135f9bf3 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -2,62 +2,12 @@ * `agentsMdReminder` domain — `IAgentAgentsMdReminderService` * implementation. * - * Self-wiring plugin: registers an `onDidExecuteTool` hook on `toolExecutor` - * that probes the directories a tool call touches for AGENTS.md files the - * system prompt did not inject, and prepends a once-per-agent - * `` to the result suggesting the model read them (head - * insertion on purpose: oversized results are truncated to a short head - * preview later in the execution pipeline, and a tail reminder would be - * silently dropped after the file was already counted as reminded). - * `Read`/`Edit`/`Write` consume the canonical file access declared by their - * resolved execution (a successful touch landing on an AGENTS.md itself marks - * just that file known), `Glob`/`Grep` consume their canonical search root, - * and `Bash` contributes its explicit `cwd` plus the literal directory - * operands extracted from the command's syntax tree (see `./bashTargets`), - * resolved against the frozen - * `sessionContext.cwd` exactly like the Bash tool itself (`args.cwd ?? - * sessionContext.cwd` — a base that deliberately differs from the live agent - * cwd after a chdir). Only calls whose `ToolDidExecuteContext.outcome` is - * `executed` are probed: preflight rejects, resolution failures, aborts, - * permission vetoes, and synthetic/duplicate results have not touched the - * requested resource and are left unchanged. The hook is ordered before - * `toolDedupe` so an executed original carries the reminder into the - * deferred result returned for a duplicate; no dedupe implementation state is - * needed here. The ordered registration throws when its target is absent, so - * scopes without `toolDedupe` fall back to plain append-order registration, - * which still lands ahead of a `toolDedupe` hook constructed later. - * - * Known-set discipline: candidates are claimed synchronously per discovered - * file into an in-memory `claimed` set (parallel calls can never duplicate a - * reminder and a failed attempt releases the claim), while `agentState` - * (`agentsMdReminder.known`) is only ever whole-value replaced after the - * reminder text is attached and the telemetry emitted — never mutated in - * place, and never ahead of the reminder it records. Probing anchors at the - * nearest existing ancestor (so `Write` into a not-yet-created directory - * still resolves), walks `findProjectRoot → touched dir`, skips chain - * directories whose candidates are all known, and applies the same - * per-directory candidate rules as the init-time load (shared through - * `profile/context`'s `findAgentsMdInDir`; blank files are included in - * neither). Directories with unknown candidates are re-statted on every - * qualifying call — deliberate, so an AGENTS.md created mid-session is - * picked up on the next touch; there is no negative cache. Probing is - * lexical like the tools' own path policy: a symlinked directory's AGENTS.md - * is discovered through the link at its lexical address, never by realpath. - * The hook never throws — a probe failure yields the untouched result. - * - * Seeding: `profile` reports the injected paths after every successful - * bind/apply/refresh and `sessionInit` re-seeds after `/init`. A prompt can - * also commit without any of those entry points — session resume and forks - * restore the already-rendered system prompt (AGENTS.md content included) - * from the wire journal or a binding snapshot. The wire restore hook seeds - * the exact persisted paths (legacy prompts recover their source annotations), - * so the first qualifying call of a never-seeded agent does not confuse the - * current filesystem with the restored prompt. The seeded cwd lives in - * `agentState` as well; restored provenance comes from `wire`/`profile`; fs - * probes go through the os `IHostFileSystem`, the home directory through - * `IHostEnvironment`, the brand home through `bootstrap`, syntax - * trees through `bashParser`, and the shown-event - * through `telemetry`. Bound at Agent scope. + * Discovers AGENTS.md files reached through `toolExecutor` and the tool path + * policy, parsing Bash targets through `bashParser` and probing through the os + * services. Restores prompt provenance through `wire` and `profile`, resolves + * roots through `sessionContext` and `bootstrap`, stores discovery state in + * `agentState`, appends through `systemReminder`, and reports through + * `telemetry`. Bound at Agent scope. */ import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; @@ -70,11 +20,9 @@ import { IBashParserService } from '#/app/bashParser/bashParser'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import type { AgentsMdReminderShownEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { ContentPart } from '#/kosong/contract/message'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import type { ExecutableToolOutput, ExecutableToolResult } from '#/tool/toolContract'; import { normalizeUserPath } from '#/tool/path-access'; import { AGENTS_MD_PLAIN_NAMES, @@ -87,6 +35,7 @@ import { } from '#/agent/profile/context'; import { ProfileModel } from '#/agent/profile/profileOps'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IWireService } from '#/wire/wire'; @@ -119,6 +68,7 @@ export class AgentAgentsMdReminderService constructor( @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentStateService private readonly states: IAgentStateService, @ISessionContext private readonly sessionContext: ISessionContext, @IHostFileSystem private readonly fs: IHostFileSystem, @@ -142,14 +92,10 @@ export class AgentAgentsMdReminderService }), ); const handler = async (ctx: ToolDidExecuteContext, next: () => Promise): Promise => { - ctx.result = await this.augmentWithReminder(ctx); + await this.probeAndRemind(ctx); await next(); }; - try { - this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler, { before: 'toolDedupe' })); - } catch { - this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); - } + this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); } seedInjected(paths: readonly string[], cwd: string): void { @@ -180,8 +126,8 @@ export class AgentAgentsMdReminderService this.seedInjected(paths, this.agentCwd); } - private async augmentWithReminder(ctx: ToolDidExecuteContext): Promise { - if (ctx.outcome !== 'executed') return ctx.result; + private async probeAndRemind(ctx: ToolDidExecuteContext): Promise { + if (ctx.outcome !== 'executed') return; const discovered: string[] = []; try { await this.ensureSeeded(); @@ -196,9 +142,8 @@ export class AgentAgentsMdReminderService } if (discovered.length === 0) { this.publishKnown(selfKnown); - return ctx.result; + return; } - const result = prependReminder(ctx.result, reminderText(discovered)); const properties: AgentsMdReminderShownEvent = { turn_id: ctx.turnId, tool_name: ctx.toolCall.name, @@ -206,11 +151,12 @@ export class AgentAgentsMdReminderService trace_id: ctx.trace?.traceId, }; this.telemetry.track2('agents_md_reminder_shown', properties); + this.reminders.appendSystemReminder(reminderText(discovered), { + kind: 'injection', + variant: 'agents_md', + }); this.publishKnown([...selfKnown, ...discovered]); - return result; - } catch { - return ctx.result; - } finally { + } catch {} finally { for (const path of discovered) this.claimed.delete(path); } } @@ -333,34 +279,12 @@ function stringArg(args: unknown, key: string): string | undefined { function reminderText(paths: readonly string[]): string { return ( - '\n' + - 'The path(s) touched by this call are covered by AGENTS.md instruction file(s) that were not part of the injected instructions:\n' + + 'The path(s) touched by a recent tool call are covered by AGENTS.md instruction file(s) that were not part of the injected instructions:\n' + paths.map((path) => `- ${path}`).join('\n') + - '\nRead them before making changes in those directories. Each file is suggested at most once per agent.' + - '\n\n\n' + '\nRead them before making changes in those directories. Each file is suggested at most once per agent.' ); } -function prependReminder(result: ExecutableToolResult, text: string): ExecutableToolResult { - const output = result.output; - let newOutput: ExecutableToolOutput; - if (typeof output === 'string') { - newOutput = text + output; - } else { - const parts: ContentPart[] = [...output]; - const first = parts[0]; - if (first !== undefined && first.type === 'text') { - parts[0] = { type: 'text', text: text + first.text }; - } else { - parts.unshift({ type: 'text', text }); - } - newOutput = parts; - } - return result.isError === true - ? { ...result, output: newOutput, isError: true } - : { ...result, output: newOutput }; -} - registerScopedService( LifecycleScope.Agent, IAgentAgentsMdReminderService, diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index e0114977f09..a7ed9d67851 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -1,40 +1,50 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { IDisposable } from "#/_base/di/lifecycle"; import type { ContentPart } from "#/kosong/contract/message"; -import type { ContextInjectionDisclosure, ContextMessage } from '#/agent/contextMemory/types'; +import type { Tool } from "#/kosong/contract/tool"; +import type { ContextMessage } from '#/agent/contextMemory/types'; -export interface ContextInjectionContext { +export interface ContextInjectionContext { readonly injectedPositions: readonly number[]; readonly lastInjectedAt: number | null; readonly lastInjection?: ContextMessage; - readonly lastDisclosure?: ContextInjectionDisclosure; + readonly lastDisclosure?: D; readonly isNewTurn: boolean; } -export type ContextInjectionContent = string | readonly ContentPart[]; +export interface ContextInjectionMessage { + readonly role: 'user' | 'system'; + readonly content: readonly ContentPart[]; + readonly tools?: readonly Tool[]; +} + +export type ContextInjectionContent = + | string + | readonly ContentPart[] + | { readonly message: ContextInjectionMessage }; -export interface ContextInjectionResult { +export interface ContextInjectionResult { readonly content: ContextInjectionContent; - readonly disclosure?: ContextInjectionDisclosure; + readonly disclosure?: D; } -export type ContextInjectionProvider = ( - context: ContextInjectionContext, +export type ContextInjectionProvider = ( + context: ContextInjectionContext, ) => | ContextInjectionContent - | ContextInjectionResult + | ContextInjectionResult | undefined - | Promise; + | Promise | undefined>; export interface IAgentContextInjectorService { readonly _serviceBrand: undefined; - register( + register( name: string, - provider: ContextInjectionProvider, + provider: ContextInjectionProvider, ): IDisposable; - injectAfterCompaction(): Promise; + reconcileWhenIdle(name: string): Promise; } export const IAgentContextInjectorService = createDecorator( diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 0bb8cc08550..2419db72995 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -1,103 +1,73 @@ /** * `contextInjector` domain — `IAgentContextInjectorService` implementation. * - * Injects registered context providers through `loop` and `systemReminder`, - * tracks their positions in `contextMemory` through `eventBus`, and reconciles - * those positions after `wire` restoration. Each provider call receives the - * newest surviving injection of its own variant (`lastInjection`) and the - * typed disclosure recorded on it (`lastDisclosure`), so providers never read - * context layout or position indexes themselves. The plain-data `isNewTurn` - * flag is registered into `agentState` (`IAgentStateService`) and read/written - * through it; `entries` stays a plain instance field (its values hold provider - * functions, not plain data). Bound at Agent scope. + * Reconciles registered model-context providers against `contextMemory` at the + * head of every loop step (before the step's request is built), so every LLM + * request sees the freshest injections. A compaction splice re-arms the + * new-turn flag for the next step. `reconcileWhenIdle` lets out-of-loop + * callers (SDK RPC surfaces) refresh one provider immediately while the loop + * is quiet. Writes reminders through `systemReminder` and reports provider + * failures through `log`. Bound at Agent scope. */ -import { toDisposable } from "#/_base/di/lifecycle"; +import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { Service } from "#/_base/di/service"; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { ILogService } from '#/_base/log/log'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentStateService } from '#/agent/state/agentState'; +import { isCompactionSummaryMessage } from '#/agent/contextMemory/compactionHandoff'; +import { IAgentLoopService, type BeforeStepContext } from '#/agent/loop/loop'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IWireService } from '#/wire/wire'; import { IAgentContextInjectorService, type ContextInjectionContent, + type ContextInjectionContext, + type ContextInjectionMessage, type ContextInjectionProvider, type ContextInjectionResult, } from './contextInjector'; interface ContextInjectionEntry { - readonly provider: ContextInjectionProvider; + readonly provider: ContextInjectionProvider; readonly name: string; - readonly positions: number[]; } -export const contextInjectorIsNewTurnKey = defineState( - 'contextInjector.isNewTurn', - () => true, -); - export class AgentContextInjectorService extends Service implements IAgentContextInjectorService { declare readonly _serviceBrand: undefined; private readonly entries = new Set(); + private compactionRearmPending = false; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentLoopService loopService: IAgentLoopService, + @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, - @IWireService wire: IWireService, - @IAgentStateService private readonly states: IAgentStateService, + @ILogService private readonly log: ILogService, ) { super(); - this.states.register(contextInjectorIsNewTurnKey); - this._register( - loopService.hooks.onWillBeginStep.register('context-injector', async (_ctx, next) => { - await next(); - await this.inject(); - }), - ); - this._register( - this.eventBus.subscribe('turn.started', () => { - this.isNewTurn = true; - }), - ); this._register( - this.eventBus.subscribe('context.spliced', (e) => { - this.handleSplice(e); - }), + loopService.hooks.onWillBeginStep.register('context-injector', (ctx, next) => + this.reconcileAroundStep(ctx, next), + ), ); this._register( - wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { - this.resyncPositions(); - await next(); + this.eventBus.subscribe('context.spliced', (splice) => { + if (isCompactionSplice(splice)) this.compactionRearmPending = true; }), ); } - private get isNewTurn(): boolean { - return this.states.get(contextInjectorIsNewTurnKey); - } - - private set isNewTurn(value: boolean) { - this.states.set(contextInjectorIsNewTurnKey, value); - } - - register( + register( name: string, - provider: ContextInjectionProvider, - ) { - const positions = findInjections(this.context.get(), name); + provider: ContextInjectionProvider, + ): IDisposable { const entry: ContextInjectionEntry = { - provider, + provider: provider as ContextInjectionProvider, name, - positions, }; this.entries.add(entry); return toDisposable(() => { @@ -105,101 +75,148 @@ export class AgentContextInjectorService extends Service implements IAgentContex }); } - async injectAfterCompaction(): Promise { - this.isNewTurn = true; - await this.inject(); + async reconcileWhenIdle(name: string): Promise { + const quiescence = this.loopService.tryAcquireQuiescence(); + if (quiescence === undefined) return; + try { + for (const entry of this.entries) { + if (entry.name !== name) continue; + await this.injectEntry(entry, false); + } + } finally { + quiescence.dispose(); + } } - private async inject(): Promise { - const isNewTurn = this.isNewTurn; - this.isNewTurn = false; - const history = this.context.get(); - for (const entry of this.entries) { - const injectedPositions: readonly number[] = [...entry.positions]; - const lastInjectedAt = injectedPositions.at(-1) ?? null; - const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt]; - const content = await entry.provider({ - injectedPositions, - lastInjectedAt, - lastInjection, - lastDisclosure: - lastInjection?.origin?.kind === 'injection' - ? lastInjection.origin.disclosure - : undefined, - isNewTurn, - }); - if (!this.entries.has(entry)) continue; - if (content === undefined) continue; - const result: ContextInjectionResult = - typeof content === 'object' && content !== null && !Array.isArray(content) - ? (content as ContextInjectionResult) - : { content: content as ContextInjectionContent }; - const origin = { - kind: 'injection' as const, - variant: entry.name, - disclosure: result.disclosure, - }; - if (typeof result.content === 'string') { - if (result.content.trim().length === 0) continue; - this.reminders.appendSystemReminder(result.content, origin); - continue; - } - if (result.content.length === 0) continue; - this.context.append({ - role: 'user', - content: [...result.content], - toolCalls: [], - origin, - }); + private async reconcileAroundStep( + ctx: BeforeStepContext, + next: (context?: BeforeStepContext) => Promise, + ): Promise { + const rearmed = this.takeCompactionRearm(); + await this.inject(ctx.firstStepOfTurn || rearmed); + await next(); + // Compaction can run inside a later handler of this same chain + // (full-compaction's beforeStep). Its splice always drops injection + // messages, so re-reconcile here — still before the step's request. + if (this.takeCompactionRearm()) { + await this.inject(true); } } - private resyncPositions(): void { - const history = this.context.get(); + /** Reads and clears the flag set when a compaction splice arrives. */ + private takeCompactionRearm(): boolean { + const pending = this.compactionRearmPending; + this.compactionRearmPending = false; + return pending; + } + + private async inject(isNewTurn: boolean): Promise { for (const entry of this.entries) { - const found = findInjections(history, entry.name); - entry.positions.length = 0; - entry.positions.push(...found); + await this.injectEntry(entry, isNewTurn); } } - private handleSplice(splice: ContextSplice): void { - let insertedInjections: Map | undefined; - splice.messages.forEach((message, offset) => { - if (message.origin?.kind !== 'injection') return; - insertedInjections ??= new Map(); - const positions = insertedInjections.get(message.origin.variant); - if (positions === undefined) { - insertedInjections.set(message.origin.variant, [splice.start + offset]); - } else { - positions.push(splice.start + offset); - } - }); - if (insertedInjections === undefined && splice.deleteCount === 0) return; + private async injectEntry(entry: ContextInjectionEntry, isNewTurn: boolean): Promise { + let content: Awaited>; + try { + content = await entry.provider(this.providerContext(entry, isNewTurn)); + } catch (error) { + this.log.error('context provider failed; skipping it', { name: entry.name, error }); + return; + } + if (!this.entries.has(entry)) return; + this.appendResult(entry, content); + } - const deletedEnd = splice.start + splice.deleteCount; - const delta = splice.messages.length - splice.deleteCount; - for (const entry of this.entries) { - const adopted = insertedInjections?.get(entry.name) ?? []; - const positions = entry.positions; - if (adopted.length === 0 && positions.length === 0) continue; - let lo = 0; - while (lo < positions.length && positions[lo]! < splice.start) lo++; - let hi = lo; - while (hi < positions.length && positions[hi]! < deletedEnd) hi++; - for (let index = hi; index < positions.length; index++) { - positions[index] = positions[index]! + delta; + private providerContext( + entry: ContextInjectionEntry, + isNewTurn: boolean, + ): ContextInjectionContext { + const history = this.context.get(); + const injectedPositions = findInjections(history, entry.name); + const lastInjectedAt = injectedPositions.at(-1) ?? null; + const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt]; + return { + injectedPositions, + lastInjectedAt, + lastInjection, + lastDisclosure: + lastInjection?.origin?.kind === 'injection' + ? lastInjection.origin.disclosure + : undefined, + isNewTurn, + }; + } + + private appendResult( + entry: ContextInjectionEntry, + content: ContextInjectionContent | ContextInjectionResult | undefined, + ): void { + if (content === undefined) return; + const result: ContextInjectionResult = isInjectionResult(content) + ? content + : { content }; + const origin = { + kind: 'injection' as const, + variant: entry.name, + disclosure: result.disclosure, + }; + const resolved = result.content; + if (typeof resolved === 'string') { + if (resolved.trim().length === 0) return; + this.reminders.appendSystemReminder(resolved, origin); + return; + } + if (isRawInjectionMessage(resolved)) { + const message = resolved.message; + if ( + message.content.length === 0 && + (message.tools === undefined || message.tools.length === 0) + ) { + return; } - positions.splice(lo, hi - lo, ...adopted); + this.context.append({ + role: message.role, + content: [...message.content], + toolCalls: [], + tools: message.tools, + origin, + }); + return; } + if (resolved.length === 0) return; + this.context.append({ + role: 'user', + content: [...resolved], + toolCalls: [], + origin, + }); } } -type ContextSplice = { - readonly start: number; +function isCompactionSplice(splice: { readonly deleteCount: number; readonly messages: readonly ContextMessage[]; -}; +}): boolean { + return splice.deleteCount > 0 && splice.messages.some(isCompactionSummaryMessage); +} + +function isRawInjectionMessage( + content: Exclude, +): content is { readonly message: ContextInjectionMessage } { + return !Array.isArray(content); +} + +function isInjectionResult( + content: ContextInjectionContent | ContextInjectionResult, +): content is ContextInjectionResult { + return ( + typeof content === 'object' && + content !== null && + !Array.isArray(content) && + 'content' in content + ); +} function findInjections( history: readonly ContextMessage[], diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 63a8af0e425..913fb6ba829 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -13,6 +13,7 @@ import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/kosong/contract/tokens'; import type { ContentPart } from '#/kosong/contract/message'; +import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder'; import summaryPrefixTemplate from './compaction-summary-prefix.md?raw'; import type { ContextMessage, PromptOrigin } from './types'; @@ -162,11 +163,9 @@ export function createCompactionElisionMessage(omittedTokens: number): ContextMe } export function buildCompactionElisionText(omittedTokens: number): string { - return [ - '', + return wrapSystemReminder( `Some of this conversation's user messages were omitted here during compaction: the messages above this note are the oldest user input, the messages below are the most recent, and roughly ${String(omittedTokens)} tokens in between were dropped. The omitted content is covered by the compaction summary at the end of the conversation.`, - '', - ].join('\n'); + ); } export function collectCompactableUserMessages(messages: readonly T[]): T[] { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 89b5be4f6d0..46950e98961 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -44,6 +44,8 @@ export interface IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void; + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean; + clear(): void; undo(count: number): UndoCut; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index fb5e00e7e48..d095be61594 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -3,11 +3,11 @@ * * Owns per-agent conversation history through `wire`, maintains measurements * with `tokenCounting`, and broadcasts live mutations through `event`. Every - * splice-shaped mutation (`clear` / `applyCompaction` / `undo`) publishes - * `context.spliced` from the live path only — replay rebuilds silently — and - * `undo` additionally truncates the measured-anchor ledger when the cut - * crosses an anchor, letting `tokenCounting` restore the surviving prefix's - * REAL size from the remaining anchors. Bound at Agent scope. + * splice-shaped mutation (`clear` / `applyCompaction` / `undo`, plus verified + * cross-model trailing removal) publishes `context.spliced` from the live path + * only — replay rebuilds silently — and truncates the measured-anchor ledger + * when a cut crosses an anchor, letting `tokenCounting` restore the surviving + * prefix's REAL size from the remaining anchors. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -89,6 +89,21 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte this.wire.dispatch(contextAppendLoopEvent({ event })); } + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { + const cutIndex = previous.length - 1; + if (cutIndex < 0) return false; + const current = this.get(); + if ( + current.length !== cutIndex || + current.some((message, index) => message !== previous[index]) + ) { + return false; + } + this.wire.dispatch(...this.sizeOpsForCut(cutIndex)); + this.publishSplice({ start: cutIndex, deleteCount: 1, messages: [] }); + return true; + } + clear(): void { const deleteCount = this.get().length; if (deleteCount === 0) return; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index a93af39c028..b672dcb1bc4 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -126,11 +126,9 @@ export const ContextModel = defineModel('contextMemory', () => }, }); -function popSwarmModeReminder(state: ContextMessage[], _payload: unknown): ContextMessage[] { - const last = state[state.length - 1]; - if (last === undefined) return state; - const origin = last.origin; - if (origin?.kind !== 'injection' || origin.variant !== 'swarm_mode') return state; +function popSwarmModeReminder(state: ContextMessage[]): ContextMessage[] { + const last = state.at(-1); + if (last?.origin?.kind !== 'injection' || last.origin.variant !== 'swarm_mode') return state; return resetFold(state.slice(0, -1)) as ContextMessage[]; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts index 5cc73599973..94060145a01 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -10,6 +10,7 @@ */ import { defineModel, type ModelDef } from '#/wire/model'; +import type { ModelReducers } from '#/wire/types'; import type { ContextMessage } from './types'; @@ -48,6 +49,7 @@ export const CHECKPOINTED_MODELS: ModelDef>[] = []; export interface CheckpointModelOptions { readonly onAppendMessage?: (current: T, message: ContextMessage) => T; + readonly reducers?: ModelReducers>; } export function defineCheckpointedModel( @@ -55,11 +57,13 @@ export function defineCheckpointedModel( initial: () => T, opts?: CheckpointModelOptions, ): ModelDef> { + const customReducers = opts?.reducers ?? {}; const def = defineModel>( name, () => ({ current: initial(), checkpoints: [] }), { reducers: { + ...customReducers, 'context.append_message': (state, { message }) => { if (isUndoAnchor(message)) { return { ...state, checkpoints: [...state.checkpoints, state.current] }; diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 5b8c59cdb3a..b21fe3c7a51 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -34,16 +34,9 @@ export interface InjectionOrigin { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: ContextInjectionDisclosure; + readonly disclosure?: unknown; } -export type ContextInjectionDisclosure = { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; -}; - export interface ShellCommandOrigin { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts index 5a005ea2837..88c70d19440 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts @@ -24,6 +24,7 @@ export interface IAgentFullCompactionService { readonly compacting: FullCompactionTask | null; begin(input: FullCompactionInput): boolean; + cancel(): void; readonly hooks: Hooks<{ onWillCompact: FullCompactionTask; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 27cd51d08dc..1f356cc19df 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -10,15 +10,16 @@ * `consecutiveOverflowCompactions`, `activeTurnId`) is registered into * `agentState` (`IAgentStateService`) and read/written through it; * `_compacting` (the in-flight job — AbortController / Promise / trace), the - * `hooks.onWillCompact` slot, the `_onDidFinishCompaction` Emitter, the - * `strategy`, and the lazily-resolved `contextInjectorService` stay instance - * fields (mechanism, not plain data). Bound at Agent scope and constructed with + * `hooks.onWillCompact` slot, the `_onDidFinishCompaction` Emitter, and the + * `strategy` stay instance fields (mechanism, not plain data). The compaction + * splice re-arms `contextInjector`'s new-turn flag, so providers re-reconcile + * at the next step head. Bound at Agent scope and constructed with * the scope so the overflow recovery handler registers before the first turn * runs. */ +import type { IDisposable } from '#/_base/di/lifecycle'; import { Service } from "#/_base/di/service"; -import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; @@ -26,7 +27,6 @@ import { defineState } from '#/_base/state/stateRegistry'; import { renderPrompt } from "#/_base/utils/render-prompt"; import { estimateTokensForMessage } from "#/kosong/contract/tokens"; import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; @@ -97,6 +97,7 @@ type CompactionTelemetryProperties = Pick< interface ActiveCompaction extends FullCompactionTask { readonly originTurnId?: number; + readonly quiescence?: IDisposable; trace?: LLMRequestTrace; blockedByTurn: boolean; } @@ -145,7 +146,6 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom private readonly strategy: CompactionStrategy; private _compacting: ActiveCompaction | null = null; - private contextInjectorService: IAgentContextInjectorService | undefined; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -154,7 +154,6 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, - @IInstantiationService private readonly instantiation: IInstantiationService, @ISessionTodoService private readonly todo: ISessionTodoService, @ITelemetryService private readonly telemetry: ITelemetryService, @IWireService private readonly wire: IWireService, @@ -248,6 +247,17 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom return this._compacting; } + cancel(): void { + const active = this._compacting; + if (active !== null) { + this.telemetry.track2('cancel', { + from: 'compacting', + trace_id: active.traceId, + }); + } + active?.abortController.abort(); + } + private getEffectiveMaxContextTokens(): number { const capability = this.profile.data().modelCapabilities; const configured = capability.max_input_tokens ?? capability.max_context_tokens; @@ -329,22 +339,37 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom if (!this.reserveCompactionSlot(data.source)) return false; const tokenCount = this.validateCompactionStart(data.source); - this.wire.dispatch(fullCompactionBegin(data)); + const quiescence = data.source === 'manual' + ? this.loopService.tryAcquireQuiescence() + : undefined; + if (data.source === 'manual' && quiescence === undefined) { + throw new Error2( + ErrorCodes.COMPACTION_UNABLE, + 'Cannot compact while a turn is active or another context change is running. Wait for it to finish, then retry.', + ); + } + try { + this.wire.dispatch(fullCompactionBegin(data)); - const active = this.createActiveCompaction( - data.source, - tokenCount, - data.source === 'auto' ? this.activeTurnId : undefined, - ); - this._compacting = active.task; - active.task.abortController.signal.addEventListener( - 'abort', - () => this.cancelActive(active.task), - { once: true }, - ); - void this.compactionWorker(active.task, data).then(active.resolve, active.reject); - void active.task.promise.catch(() => undefined); - return true; + const active = this.createActiveCompaction( + data.source, + tokenCount, + data.source === 'auto' ? this.activeTurnId : undefined, + quiescence, + ); + this._compacting = active.task; + active.task.abortController.signal.addEventListener( + 'abort', + () => this.cancelActive(active.task), + { once: true }, + ); + void this.compactionWorker(active.task, data).then(active.resolve, active.reject); + void active.task.promise.catch(() => undefined); + return true; + } catch (error) { + quiescence?.dispose(); + throw error; + } } private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { @@ -374,6 +399,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom trigger: CompactionBeginData['source'], tokenCount: number, originTurnId: number | undefined, + quiescence: IDisposable | undefined, ): { readonly task: ActiveCompaction; readonly resolve: (result: CompactionResult) => void; @@ -393,6 +419,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom trigger, tokenCount, originTurnId, + quiescence, get traceId() { return this.trace?.traceId; }, @@ -558,8 +585,6 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom this.log.error('failed to refresh system prompt after compaction', { error }); } this.lastCompactedTokenCount = result.tokensAfter; - await this.contextInjector.injectAfterCompaction(); - this.lastCompactedTokenCount = this.tokenCountWithPending(); if (!this.markCompleted(active)) { throw compactionCancelledReason(active); } @@ -585,7 +610,11 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom }); throw error; } finally { - this._onDidFinishCompaction.fire(active); + try { + this._onDidFinishCompaction.fire(active); + } finally { + active.quiescence?.dispose(); + } } } @@ -779,15 +808,6 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom private tokenCountWithPending(): number { return this.tokenCounting.get().size; } - - private get contextInjector(): IAgentContextInjectorService { - if (this.contextInjectorService === undefined) { - this.contextInjectorService = this.instantiation.invokeFunction((accessor) => - accessor.get(IAgentContextInjectorService), - ); - } - return this.contextInjectorService; - } } function findAPIStatusError(error: unknown): APIStatusError | undefined { diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index ae56369e307..98911f53d2d 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -16,7 +16,7 @@ * `StepRequest`s onto `loop` (the continuation message materializes when the * loop pops it), accounts live * turn usage through `usage`, observes terminal goal tool results through - * `toolExecutor`, writes system reminders through `systemReminder`, reports + * `toolExecutor`, appends one-time reminder events through `systemReminder`, reports * telemetry through `telemetry`, and checks main-agent eligibility through * `scopeContext`. Measures time and arms hard deadlines through `goal`'s * App-scoped deadline scheduler. Two `onBeforeExecuteTool` veto listeners @@ -59,9 +59,9 @@ import { import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; import { LoopErrors } from '#/agent/loop/errors'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { ExecutableToolResult } from '#/tool/toolContract'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; @@ -218,10 +218,9 @@ const GoalForkNoticeModel = defineModel( ); function isGoalForkClearedReminder(message: ContextMessage | undefined): boolean { - return ( - message?.origin?.kind === 'system_trigger' && - message.origin.name === GOAL_FORK_CLEARED_REMINDER_NAME - ); + const origin = message?.origin; + if (origin?.kind === 'injection') return origin.variant === GOAL_FORK_CLEARED_REMINDER_NAME; + return origin?.kind === 'system_trigger' && origin.name === GOAL_FORK_CLEARED_REMINDER_NAME; } function isGoalContinuationOrigin(origin: TurnStartedEvent['origin']): boolean { @@ -289,7 +288,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { @IEventBus private readonly eventBus: IEventBus, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @@ -319,7 +318,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { { getGoal: () => this.getGoal().goal, }, - dynamicInjector, + injector, ), ); this._register( @@ -629,8 +628,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.clearInternal(actor); if (actor === 'user') { this.reminders.appendSystemReminder(GOAL_CANCELLED_REMINDER, { - kind: 'system_trigger', - name: 'goal_cancelled', + kind: 'injection', + variant: 'goal_cancelled', }); } return snapshot; @@ -807,8 +806,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ) { this.budgetGraceTurns.add(ctx.turnId); this.reminders.appendSystemReminder(GOAL_BUDGET_STOP_REMINDER, { - kind: 'system_trigger', - name: GOAL_BUDGET_STOP_REMINDER_NAME, + kind: 'injection', + variant: GOAL_BUDGET_STOP_REMINDER_NAME, }); return true; } @@ -1021,8 +1020,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private appendForkClearedReminder(): void { if (!this.wire.getModel(GoalForkNoticeModel).reminderPending) return; this.reminders.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { - kind: 'system_trigger', - name: GOAL_FORK_CLEARED_REMINDER_NAME, + kind: 'injection', + variant: GOAL_FORK_CLEARED_REMINDER_NAME, }); } diff --git a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts index bc39fd2607d..6b6d979e397 100644 --- a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts +++ b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts @@ -13,11 +13,11 @@ export interface GoalInjectionOptions { export class GoalInjection extends Service { constructor( private readonly options: GoalInjectionOptions, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); this._register( - dynamicInjector.register('goal', ({ isNewTurn }) => (isNewTurn ? this.reminder() : undefined)), + injector.register('goal', ({ isNewTurn }) => (isNewTurn ? this.reminder() : undefined)), ); } diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts index 0c7a39e13b6..ae12da6f0e1 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts @@ -1,31 +1,23 @@ /** - * `interruptionReminder` domain (L4) — persists and restores pending - * user-interruption reminders. + * `interruptionReminder` domain — legacy wire compatibility tombstone. * - * Projects the `loop` domain's `turn.cancel` fact into the set of turns whose - * interruption reminder still has to reach the conversation, and owns the op - * that records a reminder's delivery. Consumed by the Agent-scope - * `interruptionReminderService`. + * Retains the historical `interruptionReminder.recorded` Op as a no-op so old + * Agent journals replay without unknown-record diagnostics. New interruption + * reminders append at the cancellation event point and write no domain-owned + * delivery state. Scope-agnostic. */ import { z } from 'zod'; import { defineModel } from '#/wire/model'; -export const InterruptionReminderModel = defineModel( +export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; + +export type InterruptionReminderState = null; + +export const InterruptionReminderModel = defineModel( 'interruptionReminder', - () => [], - { - reducers: { - 'turn.cancel': (state, { turnId, target, reason }) => { - if (target !== 'active' || reason !== 'user_cancelled' || turnId === undefined) { - return state; - } - if (state.includes(turnId)) return state; - return [...state, turnId].toSorted((a, b) => a - b); - }, - }, - }, + () => null, ); declare module '#/wire/types' { @@ -38,6 +30,6 @@ export const interruptionReminderRecorded = InterruptionReminderModel.defineOp( 'interruptionReminder.recorded', { schema: z.object({ turnId: z.number().int().nonnegative() }), - apply: (state, { turnId }) => state.filter((pendingTurnId) => pendingTurnId !== turnId), + apply: (state) => state, }, ); diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index e3dadecb07e..4fa1f48aa03 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -1,13 +1,12 @@ /** - * `interruptionReminder` domain (L4) — `IAgentInterruptionReminderService` implementation. + * `interruptionReminder` domain — `IAgentInterruptionReminderService` implementation. * - * Observes turn completion through `event`, persists reminder completion through - * its own wire model, reads conversation history through `contextMemory`, and - * appends model-visible notices through `systemReminder`. Reconciles reminders - * left pending by an interrupted restore. Bound at Agent scope. + * Observes completed turns through `eventBus`, appends user-cancellation facts + * through `systemReminder` at the event point, and reads `contextMemory` to + * collapse retry-only duplicate notices. Bound at Agent scope. */ -import { Service } from '#/_base/di/service'; +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -15,12 +14,9 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; -import { IWireService } from '#/wire/wire'; import { IAgentInterruptionReminderService } from './interruptionReminder'; -import { interruptionReminderRecorded, InterruptionReminderModel } from './interruptionReminderOps'; - -export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; +import { INTERRUPTION_REMINDER_VARIANT } from './interruptionReminderOps'; const INTERRUPTION_REMINDER = [ 'The previous turn was interrupted by the user before completion;', @@ -29,7 +25,7 @@ const INTERRUPTION_REMINDER = [ ].join(' '); export class AgentInterruptionReminderService - extends Service + extends Disposable implements IAgentInterruptionReminderService { declare readonly _serviceBrand: undefined; @@ -38,55 +34,25 @@ export class AgentInterruptionReminderService @IEventBus eventBus: IEventBus, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IWireService private readonly wire: IWireService, ) { super(); - this._register( - this.wire.hooks.onDidRestore.register('interruption-reminder', async (_ctx, next) => { - this.reconcilePendingReminders(); - await next(); - }), - ); this._register( eventBus.subscribe('turn.ended', (event) => { if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; - this.recordReminder(event.turnId, true); + const origin = lastComparableMessage(this.context.get())?.origin; + if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return; + this.reminders.appendSystemReminder(INTERRUPTION_REMINDER, { + kind: 'injection', + variant: INTERRUPTION_REMINDER_VARIANT, + }); }), ); } - - private reconcilePendingReminders(): void { - const pending = this.wire.getModel(InterruptionReminderModel); - for (const turnId of pending) this.recordReminder(turnId); - } - - private recordReminder(turnId: number, allowUntracked = false): void { - const pending = this.wire.getModel(InterruptionReminderModel).includes(turnId); - if (!pending && !allowUntracked) return; - if (!this.appendInterruptionReminder()) return; - if (pending) this.wire.dispatch(interruptionReminderRecorded({ turnId })); - } - - private appendInterruptionReminder(): boolean { - const before = this.context.get(); - const origin = lastDurableMessageOrigin(before); - if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return true; - this.reminders.appendSystemReminder(INTERRUPTION_REMINDER, { - kind: 'injection', - variant: INTERRUPTION_REMINDER_VARIANT, - }); - const after = this.context.get(); - if (after === before) return false; - const appended = lastDurableMessageOrigin(after); - return appended?.kind === 'injection' && appended.variant === INTERRUPTION_REMINDER_VARIANT; - } } -function lastDurableMessageOrigin( - messages: readonly ContextMessage[], -): ContextMessage['origin'] | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]!; +function lastComparableMessage(messages: readonly ContextMessage[]): ContextMessage | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]!; if ( message.role === 'assistant' && message.partial === true && @@ -95,7 +61,7 @@ function lastDurableMessageOrigin( ) { continue; } - return message.origin; + return message; } return undefined; } diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index d13c066c6a1..3fcc68993c9 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -32,6 +32,7 @@ export function isMaxStepsExceededError(error: unknown): boolean { export interface BeforeStepContext { readonly turnId: number; readonly step: number; + readonly firstStepOfTurn: boolean; readonly signal: AbortSignal; } @@ -146,6 +147,8 @@ export interface IAgentLoopService { cancel(turnId?: number, reason?: unknown): boolean; + cancelFromUser(turnId?: number): void; + tryAcquireQuiescence(): IDisposable | undefined; settled(): Promise; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index a6940a27b7f..68d3f4a700a 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -250,9 +250,26 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ); } + cancelFromUser(turnId?: number): void { + const status = this.status(); + if (status.state === 'running') { + this.telemetry.track2('cancel', { + from: 'streaming', + trace_id: status.activeTraceId, + }); + } + this.cancel(turnId); + } + tryAcquireQuiescence(): IDisposable | undefined { if (this.disposing) throw abortError('Agent loop disposed'); - if (this.activeTurnJob !== undefined || this.hasPendingRequests()) return undefined; + if ( + this.quiescenceDepth > 0 || + this.activeTurnJob !== undefined || + this.hasPendingRequests() + ) { + return undefined; + } this.quiescenceDepth += 1; return toDisposable(() => this.releaseQuiescence()); } @@ -620,6 +637,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { begun.step.signal, runtime.turnSignal, begun.step.number, + runtime.job !== undefined && begun.step.number === 1, begun.step.uuid, options.onStarted, ); @@ -804,11 +822,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { signal: AbortSignal, turnSignal: AbortSignal, currentStep: number, + firstStepOfTurn: boolean, stepUuid: string, onStarted: ((step: number) => void) | undefined, ): Promise { this.activeRequestTrace = undefined; - await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, signal }); + await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, firstStepOfTurn, signal }); const markStepStarted = this.beginStep(turnId, signal, currentStep, stepUuid, onStarted); const streamParts = this.createStreamPartHandler(turnId, markStepStarted); const request = this.llmRequester.start( @@ -839,6 +858,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnId, signal, currentStep, + firstStepOfTurn, response.usage, finishReason, ); @@ -996,12 +1016,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnId: number, signal: AbortSignal, currentStep: number, + firstStepOfTurn: boolean, usage: TokenUsage, finishReason: FinishReason, ): Promise { const context: AfterStepContext = { turnId, step: currentStep, + firstStepOfTurn, signal, usage, finishReason, diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 9901b077ede..7c033c03b05 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -6,8 +6,7 @@ * legacy loop-event observations. Also persists the terminal `turn.ended` * record (reason / error / durationMs) so downstream history rebuilds and * cold-resumed read models (e.g. the activity view) can recover how the last - * turn ended. Consumed by the Agent-scope `loopService`; the - * `interruptionReminder` domain projects `turn.cancel` into its own model. + * turn ended. Consumed by the Agent-scope `loopService`. */ import { z } from 'zod'; diff --git a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts index ab415d49aad..d5328d76b93 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts @@ -3,10 +3,10 @@ * * Owns the `permission_mode` context-injection provider. It reads the live mode * from `IAgentPermissionModeService` and registers reminders through - * `contextInjector`. Dedup is history-derived: the framework mirrors this - * variant's live positions across splices, so a reminder folded away by - * compaction (or undo) is re-announced on the next inject, matching v1's - * compaction behavior. The plain-data state (`lastMode`) is registered into + * `contextInjector`. Dedup is history-derived: the framework derives this + * variant's live positions from the surviving history, so a reminder folded + * away by compaction (or undo) is re-announced on the next inject, matching + * v1's compaction behavior. The plain-data state (`lastMode`) is registered into * `agentState` (`IAgentStateService`) and read/written through it. */ @@ -32,13 +32,13 @@ export const permissionModeLastModeKey = defineState export class PermissionModeInjection extends Service { constructor( private readonly permissionMode: Pick, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); this.states.register(permissionModeLastModeKey); this._register( - dynamicInjector.register(PERMISSION_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + injector.register(PERMISSION_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), ); } diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts index 1d04758748d..aaae863872b 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionMode.ts @@ -12,6 +12,7 @@ export interface IAgentPermissionModeService { readonly mode: PermissionMode; setMode(mode: PermissionMode): void; + setModeAndBroadcast(mode: PermissionMode): void; readonly onDidChangeMode: Event; } diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts index b556bfd9e49..a4c0bada6a2 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts @@ -5,8 +5,11 @@ * `PermissionModeModel`, mutating it only through the `permission.set_mode` Op * (`wire.dispatch(setMode({ mode }))`) and reading it through `wire.getModel`. * `setMode` emits `onDidChangeMode` after an actual change, and mode-aware - * reminders are registered through the permission-mode injection helper. Bound - * at Agent scope. + * reminders are registered through the permission-mode injection helper. + * `setModeAndBroadcast` is the user-facing entry: on top of `setMode` it + * broadcasts the mode to every agent of the session through `agentLifecycle` + * (main agent only) and tracks the `yolo_toggle` / `afk_toggle` transitions + * through `telemetry`. Bound at Agent scope. */ import type { PermissionMode } from '#/agent/permissionPolicy/types'; @@ -16,6 +19,12 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, +} from '#/session/agentLifecycle/agentLifecycle'; import { IWireService } from '#/wire/wire'; import { IAgentPermissionModeService, type PermissionModeChangedContext } from './permissionMode'; import { @@ -33,6 +42,9 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss constructor( @IWireService private readonly wire: IWireService, @IInstantiationService instantiation: IInstantiationService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @ITelemetryService private readonly telemetry: ITelemetryService, ) { super(); this._register(instantiation.createInstance(PermissionModeInjection, this)); @@ -49,6 +61,23 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss this.wire.dispatch(setMode({ mode })); if (changed) this._onDidChangeMode.fire({ mode, previousMode }); } + + setModeAndBroadcast(mode: PermissionMode): void { + const wasYolo = this.mode === 'yolo'; + const wasAuto = this.mode === 'auto'; + this.setMode(mode); + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + this.agentLifecycle.broadcastPermissionMode(mode); + } + const yoloEnabled = this.mode === 'yolo'; + if (yoloEnabled !== wasYolo) { + this.telemetry.track2('yolo_toggle', { enabled: yoloEnabled }); + } + const afkEnabled = this.mode === 'auto'; + if (afkEnabled !== wasAuto) { + this.telemetry.track2('afk_toggle', { enabled: afkEnabled }); + } + } } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts index 512f1a56586..a1ab9517f1d 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts @@ -9,6 +9,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface IAgentPluginService { readonly _serviceBrand: undefined; + + refreshSessionStart(): Promise; } export const IAgentPluginService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts new file mode 100644 index 00000000000..64a15dfbf4d --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts @@ -0,0 +1,38 @@ +/** + * `agentPlugin` domain — durable session-start guidance snapshot. + * + * Owns the Agent wire Model that freezes the main agent's rendered plugin + * session-start guidance until an explicit reload replaces it. Bound at Agent + * scope through `wire`. + */ + +import { z } from 'zod'; + +import { defineModel } from '#/wire/model'; + +export interface PluginSessionStartSnapshotState { + readonly initialized: boolean; + readonly content?: string; +} + +export const PluginSessionStartSnapshotModel = defineModel( + 'pluginSessionStartSnapshot', + () => ({ initialized: false }), +); + +declare module '#/wire/types' { + interface PersistedOpMap { + 'plugin.session_start': typeof pluginSessionStartSnapshotSet; + } +} + +export const pluginSessionStartSnapshotSet = PluginSessionStartSnapshotModel.defineOp( + 'plugin.session_start', + { + schema: z.object({ content: z.string().nullable() }), + apply: (_state, { content }) => ({ + initialized: true, + content: content ?? undefined, + }), + }, +); diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index bfc29c4388f..9d19e896c6d 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -1,40 +1,59 @@ /** * `agentPlugin` domain — `IAgentPluginService` implementation. * - * Renders session-start skills from `plugin` and `sessionSkillCatalog`, injects - * them through `contextInjector` and `systemReminder`, and uses `contextMemory` - * to neutralize stale guidance. The session-start refresh on plugin-source - * catalog changes fires only for an explicit plugin reload: a mutation-driven - * reload (install / enable / disable / remove) skips it — the live session - * keeps the guidance it started with — and instead appends a `plugin_change` + * Renders session-start skills from `plugin` and `sessionSkillCatalog` through + * `contextInjector`, reconciling the desired instructions against the latest + * surviving render reported by the injector (`lastInjection`) and unwrapped + * through `systemReminder`. The rendered guidance is frozen through a durable + * `wire` snapshot until an explicit reload. The session-start refresh on + * plugin-source catalog changes fires only for an explicit plugin reload: a + * mutation-driven reload (install / enable / disable / remove) skips it — the + * live session keeps the guidance it started with — and instead appends a `plugin_change` * system reminder through `systemReminder` (`plugin` `onDidMutate` — never on * an explicit reload, whose resumed session would otherwise inherit a stale * notice), naming the mutated plugin and telling the model the live session * keeps its original prompt and tool set until `/new` or `/reload`. * Main-agent-only (v1 parity): the service * self-gates on `agentId === 'main'`; Agent scope creation instantiates it for - * every agent, so other agents construct it as a no-op. Resolves - * session prompt context through `sessionContext` and reports missing skills - * through `log`. Bound at Agent scope. + * every agent, so other agents construct it as a no-op. Resolves session + * prompt context through `sessionContext` and reports missing skills through + * `log` (once per plugin:skill key — the provider re-renders on every + * boundary, so an unguarded warn would repeat every step); stores the + * refresh signal through `agentState`, consumed only after a successful + * render so a failed render retries at the next boundary. Bound at Agent + * scope. */ import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/_base/state/stateRegistry'; import { escapeXmlAttr } from '#/_base/utils/xml-escape'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, +} from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { + IAgentSystemReminderService, + systemReminderContent, +} from '#/agent/systemReminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart, PluginMutation } from '#/app/plugin/types'; import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { IWireService } from '#/wire/wire'; import { IAgentPluginService } from './agentPlugin'; +import { + PluginSessionStartSnapshotModel, + pluginSessionStartSnapshotSet, +} from './agentPluginOps'; const SESSION_START_INJECTION_VARIANT = 'plugin_session_start'; @@ -58,8 +77,20 @@ function renderPluginChangeReminder(mutation: PluginMutation): string { const MAIN_AGENT_ID = 'main'; +const SUPERSEDES_SUFFIX = + 'This supersedes any earlier plugin_session_start reminder in this session.'; + +const NO_ACTIVE_SESSION_STARTS = + `There are currently no active plugin session starts. ${SUPERSEDES_SUFFIX}`; + +export const pluginSessionStartRefreshPendingKey = defineState( + 'agentPlugin.sessionStartRefreshPending', + () => false, +); + export class AgentPluginService extends Service implements IAgentPluginService { declare readonly _serviceBrand: undefined; + private readonly warnedMissingSessionStartSkills = new Set(); // Count of mutation-driven plugin reloads whose catalog change has not // reached this agent yet. `reloadAndNotify` fires `onDidMutate` @@ -69,24 +100,23 @@ export class AgentPluginService extends Service implements IAgentPluginService { private pendingMutationCatalogChanges = 0; constructor( - @IAgentScopeContext scopeContext: IAgentScopeContext, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentContextInjectorService private readonly injector: IAgentContextInjectorService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IPluginService private readonly plugins: IPluginService, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @ISessionContext private readonly sessionContext: ISessionContext, @ILogService private readonly log: ILogService, + @IAgentStateService private readonly states: IAgentStateService, + @IWireService private readonly wire: IWireService, ) { super(); if (scopeContext.agentId !== MAIN_AGENT_ID) return; + this.states.register(pluginSessionStartRefreshPendingKey); this._register( - injector.register( - SESSION_START_INJECTION_VARIANT, - async ({ injectedPositions }) => { - if (injectedPositions.length > 0) return undefined; - return this.renderSessionStartReminder(); - }, + injector.register(SESSION_START_INJECTION_VARIANT, (injection) => + this.reconcileSessionStartReminder(injection), ), ); this._register( @@ -101,7 +131,7 @@ export class AgentPluginService extends Service implements IAgentPluginService { this.pendingMutationCatalogChanges--; return; } - void this.appendFreshSessionStartReminder(); + this.refreshPending = true; }), ); this._register( @@ -115,6 +145,21 @@ export class AgentPluginService extends Service implements IAgentPluginService { ); } + private get refreshPending(): boolean { + return this.states.get(pluginSessionStartRefreshPendingKey); + } + + private set refreshPending(value: boolean) { + this.states.set(pluginSessionStartRefreshPendingKey, value); + } + + async refreshSessionStart(): Promise { + if (this.scopeContext.agentId !== MAIN_AGENT_ID) return; + this.refreshPending = true; + await this.skillCatalog.ready; + await this.injector.reconcileWhenIdle(SESSION_START_INJECTION_VARIANT); + } + private async renderSessionStartReminder(): Promise { const sessionStarts = await this.plugins.enabledSessionStarts(); if (sessionStarts.length === 0) return undefined; @@ -124,47 +169,96 @@ export class AgentPluginService extends Service implements IAgentPluginService { catalog: this.skillCatalog.catalog, log: this.log, sessionId: this.sessionContext.sessionId, + warnedSkills: this.warnedMissingSessionStartSkills, }); } - async appendFreshSessionStartReminder(): Promise { - const reminder = await this.renderSessionStartReminder(); - if (reminder !== undefined) { - this.reminders.appendSystemReminder( - `${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`, - { kind: 'injection', variant: SESSION_START_INJECTION_VARIANT }, - ); - } else if (shouldNeutralizePluginSessionStart(this.context.get())) { - this.reminders.appendSystemReminder( - 'There are currently no active plugin session starts. ' + - 'This supersedes any earlier plugin_session_start reminder in this session.', - { kind: 'injection', variant: SESSION_START_INJECTION_VARIANT }, - ); + private async reconcileSessionStartReminder( + injection: ContextInjectionContext, + ): Promise { + const forceRefresh = this.refreshPending; + const desired = await this.resolveDesiredSessionStart(injection, forceRefresh); + this.refreshPending = false; + const latest = injection.lastInjection; + if (desired === undefined) { + if ( + latest === undefined && + (!forceRefresh || !shouldNeutralizePluginSessionStart(this.context.get())) + ) { + return undefined; + } + if (latest !== undefined && systemReminderContent(latest) === NO_ACTIVE_SESSION_STARTS) { + return undefined; + } + return NO_ACTIVE_SESSION_STARTS; + } + if (latest === undefined) return desired; + const rendered = systemReminderContent(latest); + if ( + !forceRefresh && + (rendered === desired.trim() || rendered === `${desired}\n\n${SUPERSEDES_SUFFIX}`.trim()) + ) { + return undefined; + } + return `${desired}\n\n${SUPERSEDES_SUFFIX}`; + } + + private async resolveDesiredSessionStart( + injection: ContextInjectionContext, + forceRefresh: boolean, + ): Promise { + const snapshot = this.wire.getModel(PluginSessionStartSnapshotModel); + if (!forceRefresh && snapshot.initialized) return snapshot.content; + if (!forceRefresh && injection.lastInjection !== undefined) { + const rendered = systemReminderContent(injection.lastInjection); + if (rendered !== undefined) { + const content = frozenSessionStartContent(rendered); + this.recordSessionStartSnapshot(content); + return content; + } } + const content = await this.renderSessionStartReminder(); + this.recordSessionStartSnapshot(content); + return content; + } + + private recordSessionStartSnapshot(content: string | undefined): void { + this.wire.dispatch(pluginSessionStartSnapshotSet({ content: content ?? null })); } } +function frozenSessionStartContent(rendered: string): string | undefined { + if (rendered === NO_ACTIVE_SESSION_STARTS) return undefined; + const suffix = `\n\n${SUPERSEDES_SUFFIX}`; + return rendered.endsWith(suffix) ? rendered.slice(0, -suffix.length) : rendered; +} + interface RenderPluginSessionStartReminderInput { readonly sessionStarts: readonly EnabledPluginSessionStart[]; readonly catalog: SkillCatalog | undefined; readonly log?: { warn(message: string, payload?: unknown): void }; readonly sessionId?: string; + readonly warnedSkills: Set; } function renderPluginSessionStartReminder( input: RenderPluginSessionStartReminderInput, ): string | undefined { - const { sessionStarts, catalog, log, sessionId } = input; + const { sessionStarts, catalog, log, sessionId, warnedSkills } = input; if (sessionStarts.length === 0) return undefined; if (catalog === undefined) return undefined; const blocks: string[] = []; for (const sessionStart of sessionStarts) { const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); if (skill === undefined) { - log?.warn('plugin sessionStart skill not found', { - pluginId: sessionStart.pluginId, - skillName: sessionStart.skillName, - }); + const key = `${sessionStart.pluginId}:${sessionStart.skillName}`; + if (!warnedSkills.has(key)) { + warnedSkills.add(key); + log?.warn('plugin sessionStart skill not found', { + pluginId: sessionStart.pluginId, + skillName: sessionStart.skillName, + }); + } continue; } blocks.push( diff --git a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts new file mode 100644 index 00000000000..4838da64fbf --- /dev/null +++ b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts @@ -0,0 +1,41 @@ +/** + * `pluginCommand` domain — Agent-scoped plugin command activation contract. + * + * `IAgentPluginCommandService.activate` drives a user-slash plugin command + * into the agent's prompt pipeline: the command definition lives in the + * App-scope `plugin` domain, while activation (argument expansion, the + * `plugin_command.activated` domain event, prompt enqueue) must run inside the + * agent scope. Bound at Agent scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ActivatePluginCommandPayload { + readonly pluginId: string; + readonly commandName: string; + readonly args?: string | undefined; +} + +export interface PluginCommandActivatedEvent { + readonly type: 'plugin_command.activated'; + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; +} + +declare module '#/app/event/eventBus' { + interface DomainEventMap { + 'plugin_command.activated': PluginCommandActivatedEvent; + } +} + +export interface IAgentPluginCommandService { + readonly _serviceBrand: undefined; + + activate(payload: ActivatePluginCommandPayload): Promise; +} + +export const IAgentPluginCommandService: ServiceIdentifier = + createDecorator('agentPluginCommandService'); diff --git a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts new file mode 100644 index 00000000000..9690cd3746c --- /dev/null +++ b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts @@ -0,0 +1,111 @@ +/** + * `pluginCommand` domain — `IAgentPluginCommandService` implementation. + * + * Resolves the command definition through `plugin` (`IPluginService`), expands + * its arguments, publishes the `plugin_command.activated` domain event through + * `eventBus`, enqueues the expanded body as a user message through `prompt`, + * and — for the main agent only — persists the derived title/lastPrompt + * through `sessionMetadata`, publishing the live update through `event`. + * Bound at Agent scope. + */ + +import { randomUUID } from 'node:crypto'; + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IEventBus } from '#/app/event/eventBus'; +import { IEventService } from '#/app/event/event'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { expandCommandArguments } from '#/app/plugin/commands'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { promptMetadataTextFromText } from '#/agent/prompt/promptMetadataText'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetadata'; + +import { + IAgentPluginCommandService, + type ActivatePluginCommandPayload, +} from './pluginCommand'; + +export class AgentPluginCommandService implements IAgentPluginCommandService { + declare readonly _serviceBrand: undefined; + + constructor( + @IPluginService private readonly plugins: IPluginService, + @IAgentPromptService private readonly promptService: IAgentPromptService, + @IEventBus private readonly eventBus: IEventBus, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) { } + + async activate(payload: ActivatePluginCommandPayload): Promise { + const commands = await this.plugins.listPluginCommands(); + const def = commands.find( + (command) => command.pluginId === payload.pluginId && command.name === payload.commandName, + ); + if (def === undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `Plugin command "${payload.pluginId}:${payload.commandName}" was not found`, + ); + } + const commandArgs = payload.args ?? ''; + const expanded = expandCommandArguments(def.body, commandArgs); + const origin = { + kind: 'plugin_command' as const, + activationId: randomUUID(), + pluginId: payload.pluginId, + commandName: payload.commandName, + commandArgs: payload.args, + trigger: 'user-slash' as const, + }; + this.eventBus.publish({ + type: 'plugin_command.activated', + activationId: origin.activationId, + pluginId: origin.pluginId, + commandName: origin.commandName, + commandArgs: origin.commandArgs, + trigger: origin.trigger, + }); + await this.promptService.enqueue({ message: { + role: 'user', + content: [{ type: 'text', text: expanded }], + toolCalls: [], + origin, + } }); + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + promptMetadataTextFromPluginCommand(payload), + ); + } + } +} + +function promptMetadataTextFromPluginCommand( + payload: ActivatePluginCommandPayload, +): string | undefined { + const args = payload.args?.trim(); + const command = `/${payload.pluginId}:${payload.commandName}`; + return promptMetadataTextFromText( + args === undefined || args.length === 0 ? command : `${command} ${args}`, + ); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentPluginCommandService, + AgentPluginCommandService, + ScopeActivation.OnScopeCreated, + 'pluginCommand', +); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 163c2e5d0df..b344eec90af 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -121,7 +121,6 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { subagentDisplayModel } from '#/session/subagent/configSection'; import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { BUILTIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; @@ -758,7 +757,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const maxContextTokens = capabilities?.max_input_tokens ?? capabilities?.max_context_tokens; this.eventBus.publish({ type: 'agent.status.updated', - model: subagentDisplayModel(this.config, modelAlias), + model: modelAlias, thinkingEffort: includeThinkingEffort ? this.getEffectiveThinkingLevel() : undefined, diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index d5045dd0256..73f1b49e4dc 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -1,6 +1,7 @@ import { createDecorator } from '#/_base/di/instantiation'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { Turn, TurnResult } from '#/agent/loop/loop'; +import type { ContentPart } from '#/kosong/contract/message'; import type { Hooks } from '#/hooks'; export interface PromptSubmitContext { @@ -47,9 +48,23 @@ export interface PromptQueueSnapshot { readonly pending: readonly PromptSnapshot[]; } +export interface PromptPayload { + readonly input: readonly ContentPart[]; +} + +export interface SteerPayload { + readonly input: readonly ContentPart[]; +} + +export interface PromptLaunchResult { + readonly turn_id: number; +} + export interface IAgentPromptService { readonly _serviceBrand: undefined; enqueue(input: PromptInput): Promise; + submit(payload: PromptPayload): Promise; + submitSteer(payload: SteerPayload): Promise; list(): PromptQueueSnapshot; steer(promptIds: readonly string[]): Promise; abort(promptId: string, reason?: Error): boolean; diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index efd40bac1c6..f41ded27c77 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -4,7 +4,14 @@ * Assigns prompt and message identities, serializes user prompts through an * active slot and FIFO, converts selected pending prompts into active-turn * steers, settles lifecycle handles, and keeps system input outside the prompt - * resource model. The pure-data `launching` flag is registered into + * resource model. `submit` / `submitSteer` are the wire-facing user entry + * points: they track `input_steer` through `telemetry`, persist the derived + * title/lastPrompt through `sessionMetadata` for the main agent only + * (publishing the live update through `event`), enqueue, and settle + * `{turn_id}` from the launch handle. Session tool gating is an edge + * concern: callers apply `IAgentToolPolicyService.setSessionDisabledTools` + * before submitting, the way kap-server's prompt route composes it. + * The pure-data `launching` flag is registered into * `agentState` (`IAgentStateService`) and read/written through it; the * `active` / `pending` / `steered` records stay plain fields because their * `Record` values carry Deferred promise handles (the container only holds @@ -31,20 +38,31 @@ import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/kosong/contract/message'; import { IEventBus } from '#/app/event/eventBus'; -import { ErrorCodes, Error2 } from '#/errors'; +import { IEventService } from '#/app/event/event'; +import { ErrorCodes, Error2, isError2 } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; import { IWireService } from '#/wire/wire'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetadata'; import { IAgentPromptService, type PromptCompletion, type PromptHandle, type PromptInput, + type PromptLaunchResult, + type PromptPayload, type PromptQueueSnapshot, type PromptSnapshot, type PromptState, type PromptSubmitContext, + type SteerPayload, } from './prompt'; +import { promptMetadataTextFromContentParts } from './promptMetadataText'; import { PromptStepRequest, RetryStepRequest, SteerStepRequest } from './promptStepRequests'; declare module '#/app/event/eventBus' { @@ -83,6 +101,11 @@ export class AgentPromptService implements IAgentPromptService { @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, @IAgentStateService private readonly states: IAgentStateService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { this.states.register(promptLaunchingKey); toolExecutor.hooks.onDidExecuteTool.register('prompt-service-delivery', async (ctx, next) => { @@ -129,6 +152,64 @@ export class AgentPromptService implements IAgentPromptService { return record.handle; } + async submit(payload: PromptPayload): Promise { + await this.updatePromptMetadata(promptMetadataTextFromContentParts(payload.input)); + const handle = await this.enqueue({ message: { + role: 'user', + content: [...payload.input], + toolCalls: [], + origin: { kind: 'user' }, + } }); + if (handle.state === 'pending') return undefined; + const turn = await handle.launched; + return turn === undefined ? undefined : { turn_id: turn.id }; + } + + async submitSteer(payload: SteerPayload): Promise { + this.telemetry.track2('input_steer', { parts: payload.input.length }); + // A steer is user input like a prompt — and can even launch the session's + // first turn (e.g. goal mode) — so keep title/lastPrompt in sync the same + // way, matching v1. + await this.updatePromptMetadata(promptMetadataTextFromContentParts(payload.input)); + const queued = await this.enqueue({ message: { + role: 'user', + content: [...payload.input], + toolCalls: [], + } }); + if (queued.state !== 'pending') { + // No active prompt at enqueue time, so the enqueue itself already + // launched this input as its own turn (idle session, or a goal-turn + // boundary where the previous turn just ended) — v1's + // steer-degrades-to-launch end state. Return that turn instead of + // rejecting on a steer-by-id that can never find the record pending. + const turn = await queued.launched; + return turn === undefined ? undefined : { turn_id: turn.id }; + } + try { + const [steered] = await this.steer([queued.id]); + const turn = await steered?.launched; + return turn === undefined ? undefined : { turn_id: turn.id }; + } catch (error) { + // Pending but nothing active to steer into (a manual compaction holds + // the context): the message stays queued and launches once compaction + // finishes, so report it as queued rather than failing the steer. + if (isError2(error) && error.code === ErrorCodes.PROMPT_NOT_FOUND) return undefined; + throw error; + } + } + + private async updatePromptMetadata(text: string | undefined): Promise { + if (this.scopeContext.agentId !== MAIN_AGENT_ID) return; + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + text, + ); + } + list(): PromptQueueSnapshot { return { active: this.active === undefined ? undefined : snapshot(this.active), pending: this.pending.map(snapshot) }; } diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts index f84ede987e0..c67687180fd 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts @@ -5,8 +5,9 @@ * `PromptStepRequest` / `SteerStepRequest` carry an already-built user * `ContextMessage` (image-compression captions pre-split), apply the image * format gate as the last funnel before the history, and materialize it - * at pop time — caption reminders first, message second, mirroring the old - * `appendPrompt` ordering. `PromptStepRequest` uses `newTurn`, seeding the + * at pop time — caption reminders are appended before the host message, + * preserving the prompt-owned undo boundary. + * `PromptStepRequest` uses `newTurn`, seeding the * `turn.prompt` record from its message. `SteerStepRequest` uses * `activeOrNewTurn`, is mergeable, and survives turn boundaries; it records * the `turn.steer` wire op on materialization and unregisters itself from the diff --git a/packages/agent-core-v2/src/agent/replayBuilder/types.ts b/packages/agent-core-v2/src/agent/replayBuilder/types.ts index 8d4b6b49a99..0f13665993d 100644 --- a/packages/agent-core-v2/src/agent/replayBuilder/types.ts +++ b/packages/agent-core-v2/src/agent/replayBuilder/types.ts @@ -7,10 +7,26 @@ import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/per import type { PermissionData, PermissionMode } from '#/agent/permissionPolicy/types'; import type { PlanData } from '#/features/plan/plan'; import type { ToolInfo } from '#/tool/toolContract'; -import type { SessionSummary } from '#/agent/rpc/core-api'; import type { UsageStatus } from '#/agent/usage/usage'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; +export type JsonObject = { readonly [key: string]: JsonValue }; + +export interface SessionSummary { + readonly id: string; + readonly title?: string | undefined; + readonly lastPrompt?: string; + readonly workDir: string; + readonly sessionDir: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived?: boolean | undefined; + readonly metadata?: JsonObject | undefined; + readonly additionalDirs?: readonly string[]; +} + type AgentType = 'main' | 'sub'; export type AgentReplayRecordPayload = diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts deleted file mode 100644 index f1c68603ccd..00000000000 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * `rpc` domain — v2 native RPC contract. - * - * Request/response payloads and event types for the engine's native RPC - * surface. `PromptPayload.disabledTools` is the client-managed session - * denylist, applied before the prompt is enqueued: full-replace semantics, the profile's own - * `disallowedTools` always survive, omitting the field keeps the persisted - * value, and `[]` clears the client portion. It is ignored by engines without - * profile support. - */ - -import type { AgentContextData } from '#/agent/contextMemory/types'; -import type { AgentCommandInfo } from '#/agent/command/agentCommand'; -import type { - GoalBudgetLimits, - GoalBudgetReport, - GoalChange, - GoalChangeStats, - GoalSnapshot, - GoalStatus, - GoalToolResult, -} from '#/agent/goal/types'; -import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import type { SwarmModeTrigger } from '#/agent/swarm/swarm'; -import type { ToolDisclosure, ToolInfo } from '#/tool/toolContract'; -import type { ResolvedConfig } from '#/app/config/config'; -import type { ExperimentalFeatureState } from '#/app/flag/flag'; -import type { ResumeSessionResult } from '#/agent/replayBuilder/types'; -import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import type { ContentPart } from '#/kosong/contract/message'; -import type { SessionWarning } from '#/app/sessionLegacy/sessionProtocol'; - -import type { ExportSessionPayload, ExportSessionResult } from '#/app/sessionExport/sessionExport'; -import type { PluginCommandDef, PluginInfo, PluginSummary, ReloadSummary } from '#/app/plugin/types'; -import type { WithAgentId, WithSessionId } from './types'; - -export type { ExportSessionManifest, ExportSessionPayload, ExportSessionResult, ShellEnvironment } from '#/app/sessionExport/sessionExport'; - -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; -export type JsonObject = { readonly [key: string]: JsonValue }; - -export type Unsubscribe = () => void; - -export type TextPromptPart = Extract; -export type PromptPart = Extract; - -export type PromptInput = readonly PromptPart[]; - -export type EmptyPayload = {}; -export type SessionMetadataPatch = Partial>; - -export interface ClientTelemetryInfo { - readonly id?: string | undefined; - readonly name?: string | undefined; - readonly version?: string | undefined; - readonly uiMode?: string | undefined; -} - -export interface CreateSessionPayload { - readonly id?: string | undefined; - readonly workDir: string; - readonly model?: string | undefined; - readonly thinking?: string | undefined; - readonly permission?: PermissionMode | undefined; - readonly metadata?: JsonObject | undefined; - readonly additionalDirs?: readonly string[]; - readonly client?: ClientTelemetryInfo | undefined; -} - -export interface CloseSessionPayload { - readonly sessionId: string; -} - -export interface ArchiveSessionPayload { - readonly sessionId: string; -} - -export interface ResumeSessionPayload { - readonly sessionId: string; - readonly additionalDirs?: readonly string[]; -} - -export interface ReloadSessionPayload { - readonly sessionId: string; - readonly forcePluginSessionStartReminder?: boolean | undefined; -} - -export interface ForkSessionPayload { - readonly sessionId: string; - readonly id?: string; - readonly title?: string; - readonly metadata?: JsonObject; -} - -export interface ListSessionsPayload { - readonly workDir?: string; - readonly sessionId?: string; - readonly includeArchive?: boolean; -} - -export interface CoreInfo { - readonly version: string; -} - -export interface SessionSummary { - readonly id: string; - readonly title?: string | undefined; - readonly lastPrompt?: string; - readonly workDir: string; - readonly sessionDir: string; - readonly createdAt: number; - readonly updatedAt: number; - readonly archived?: boolean | undefined; - readonly metadata?: JsonObject | undefined; - readonly additionalDirs?: readonly string[]; -} - -export interface PromptPayload { - readonly input: readonly ContentPart[]; - readonly disabledTools?: readonly string[]; -} -export interface RunShellCommandPayload { - readonly command: string; - readonly commandId?: string; -} -export interface ShellCommandResult { - readonly stdout: string; - readonly stderr: string; - readonly isError?: boolean; - readonly backgrounded?: boolean; -} -export interface CancelShellCommandPayload { - readonly commandId: string; -} -export interface SteerPayload { - readonly input: readonly ContentPart[]; -} -export interface CancelPayload { - readonly turnId?: number; -} -export interface SetThinkingPayload { - readonly level: string; -} -export interface SetPermissionPayload { - readonly mode: PermissionMode; -} -export interface SetModelPayload { - readonly model: string; -} -export interface SetModelResult { - readonly model: string; - readonly providerName?: string | undefined; -} -export interface CancelPlanPayload { - readonly id?: string; -} -export interface EnterSwarmPayload { - readonly trigger: SwarmModeTrigger; -} -export interface BeginCompactionPayload { - readonly instruction?: string; -} -export interface UndoHistoryPayload { - readonly count: number; -} -export interface RegisterToolPayload { - readonly name: string; - readonly description: string; - readonly parameters: Record; - readonly disclosure?: ToolDisclosure; -} -export interface UnregisterToolPayload { - readonly name: string; -} -export interface SetActiveToolsPayload { - readonly names: readonly string[]; -} -export interface StopTaskPayload { - readonly taskId: string; - readonly reason?: string; -} -export interface DetachTaskPayload { - readonly taskId: string; -} -export interface GetTaskOutputPayload { - readonly taskId: string; - readonly tail?: number; -} -export interface GetTasksPayload { - readonly activeOnly?: boolean; - readonly limit?: number; -} -export interface SkillSummary { - readonly name: string; - readonly description: string; - readonly path: string; - readonly source: 'builtin' | 'user' | 'extra' | 'project'; - readonly type?: string | undefined; - readonly disableModelInvocation?: boolean | undefined; - readonly isSubSkill?: boolean | undefined; -} - -export interface ActivateSkillPayload { - readonly name: string; - readonly args?: string | undefined; -} - -export interface ActivatePluginCommandPayload { - readonly pluginId: string; - readonly commandName: string; - readonly args?: string | undefined; -} - -export interface RunCommandPayload { - readonly name: string; - readonly args?: string | undefined; -} - -export interface McpServerInfo { - readonly name: string; - readonly transport: 'stdio' | 'http' | 'sse'; - readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth' | 'removed'; - readonly toolCount: number; - readonly error?: string; -} - -export interface McpStartupMetrics { - readonly durationMs: number; -} - -export interface ReconnectMcpServerPayload { - readonly name: string; -} - -export interface InstallPluginPayload { - readonly source: string; -} - -export interface SetPluginEnabledPayload { - readonly id: string; - readonly enabled: boolean; -} - -export interface SetPluginMcpServerEnabledPayload { - readonly id: string; - readonly server: string; - readonly enabled: boolean; -} - -export interface RemovePluginPayload { - readonly id: string; -} - -export interface GetPluginInfoPayload { - readonly id: string; -} - -export type ReloadPluginsResult = ReloadSummary; -export type { PluginSummary, PluginInfo }; - -export interface RenameSessionPayload { - readonly title: string; -} - -export interface UpdateSessionMetadataPayload { - readonly metadata: SessionMetadataPatch; -} - -export type { - GoalBudgetLimits, - GoalBudgetReport, - GoalChange, - GoalChangeStats, - GoalSnapshot, - GoalStatus, - GoalToolResult, -}; - -export interface CreateGoalPayload { - readonly objective: string; - readonly replace?: boolean; -} - -export interface GetKimiConfigPayload { - readonly reload?: boolean; -} - -export interface ConfigDiagnostics { - readonly warnings: readonly string[]; -} - -export type SetKimiConfigPayload = ResolvedConfig; - -export interface RemoveKimiProviderPayload { - readonly providerId: string; -} - -export interface PromptLaunchResult { - readonly turn_id: number; -} - -export interface AgentAPI { - prompt: (payload: PromptPayload) => PromptLaunchResult | undefined; - steer: (payload: SteerPayload) => PromptLaunchResult | undefined; - cancel: (payload: CancelPayload) => void; - undoHistory: (payload: UndoHistoryPayload) => Promise; - setPermission: (payload: SetPermissionPayload) => void; - cancelCompaction: (payload: EmptyPayload) => void; - activateSkill: (payload: ActivateSkillPayload) => PromptLaunchResult | undefined; - activatePluginCommand: (payload: ActivatePluginCommandPayload) => void; - listCommands: (payload: EmptyPayload) => readonly AgentCommandInfo[]; - runCommand: (payload: RunCommandPayload) => Promise; - getContext: (payload: EmptyPayload) => AgentContextData; - getTools: (payload: EmptyPayload) => readonly ToolInfo[]; -} - -type AgentAPIWithId = WithAgentId; - -export interface SessionAPI extends AgentAPIWithId { - renameSession: (payload: RenameSessionPayload) => void; - updateSessionMetadata: (payload: UpdateSessionMetadataPayload) => void; - getSessionMetadata: (payload: EmptyPayload) => SessionMeta; - listSkills: (payload: EmptyPayload) => readonly SkillSummary[]; - listPluginCommands: (payload: EmptyPayload) => readonly PluginCommandDef[]; - listMcpServers: (payload: EmptyPayload) => readonly McpServerInfo[]; - getMcpStartupMetrics: (payload: EmptyPayload) => McpStartupMetrics; - reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; - generateAgentsMd: (payload: EmptyPayload) => void; - getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[]; -} - -type SessionAPIWithId = WithSessionId; - -export interface CoreAPI extends SessionAPIWithId { - getCoreInfo: (payload: EmptyPayload) => CoreInfo; - getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[]; - getKimiConfig: (payload: GetKimiConfigPayload) => ResolvedConfig; - getConfigDiagnostics: (payload: EmptyPayload) => ConfigDiagnostics; - setKimiConfig: (payload: SetKimiConfigPayload) => ResolvedConfig; - removeKimiProvider: (payload: RemoveKimiProviderPayload) => ResolvedConfig; - createSession: (payload: CreateSessionPayload) => SessionSummary; - closeSession: (payload: CloseSessionPayload) => void; - archiveSession: (payload: ArchiveSessionPayload) => void; - resumeSession: (payload: ResumeSessionPayload) => ResumeSessionResult; - reloadSession: (payload: ReloadSessionPayload) => ResumeSessionResult; - forkSession: (payload: ForkSessionPayload) => ResumeSessionResult; - listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[]; - exportSession: (payload: ExportSessionPayload) => ExportSessionResult; - listPlugins: (payload: EmptyPayload) => readonly PluginSummary[]; - installPlugin: (payload: InstallPluginPayload) => PluginSummary; - setPluginEnabled: (payload: SetPluginEnabledPayload) => void; - setPluginMcpServerEnabled: (payload: SetPluginMcpServerEnabledPayload) => void; - removePlugin: (payload: RemovePluginPayload) => void; - reloadPlugins: (payload: EmptyPayload) => ReloadPluginsResult; - getPluginInfo: (payload: GetPluginInfoPayload) => PluginInfo; -} diff --git a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts deleted file mode 100644 index 519e2a440f9..00000000000 --- a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * `rpc` domain (Agent) — v1-compatible prompt metadata helpers. - * - * Derives title and last-prompt text from native and legacy prompt payloads, - * persists metadata through `sessionMetadata`, and publishes live updates - * through `event`. - */ - -import type { IEventService } from '#/app/event/event'; -import type { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; - -import { - promptMetadataTextFromContentParts, - promptMetadataTextFromText, - titleFromPromptMetadataText, -} from '#/agent/prompt/promptMetadataText'; - -import type { - ActivatePluginCommandPayload, - ActivateSkillPayload, - PromptPayload, -} from './core-api'; - -export { promptMetadataTextFromContentParts, titleFromPromptMetadataText }; - -export function promptMetadataTextFromPayload(payload: PromptPayload): string | undefined { - return promptMetadataTextFromContentParts(payload.input); -} - -export function promptMetadataTextFromSkill(payload: ActivateSkillPayload): string | undefined { - const args = payload.args?.trim(); - return promptMetadataTextFromText( - args === undefined || args.length === 0 ? `/${payload.name}` : `/${payload.name} ${args}`, - ); -} - -export function promptMetadataTextFromPluginCommand( - payload: ActivatePluginCommandPayload, -): string | undefined { - const args = payload.args?.trim(); - const command = `/${payload.pluginId}:${payload.commandName}`; - return promptMetadataTextFromText( - args === undefined || args.length === 0 ? command : `${command} ${args}`, - ); -} - -export function isUntitled(title: string | undefined): boolean { - return title === undefined || title.trim().length === 0 || title === 'New Session'; -} - -export interface PromptMetadataUpdateTarget { - readonly metadata: ISessionMetadata; - readonly eventService: IEventService; - readonly sessionId: string; -} - -export async function applyPromptMetadataUpdate( - target: PromptMetadataUpdateTarget, - text: string | undefined, -): Promise { - if (text === undefined) return; - const current = await target.metadata.read(); - const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = { - lastPrompt: text, - }; - if (!current.isCustomTitle && isUntitled(current.title)) { - patch.title = titleFromPromptMetadataText(text); - patch.isCustomTitle = false; - } - await target.metadata.update(patch); - target.eventService.publish({ - type: 'session.meta.updated', - payload: { - agentId: 'main', - sessionId: target.sessionId, - title: patch.title, - patch: { - title: patch.title, - isCustomTitle: patch.isCustomTitle, - lastPrompt: text, - }, - }, - }); -} diff --git a/packages/agent-core-v2/src/agent/rpc/rpc.ts b/packages/agent-core-v2/src/agent/rpc/rpc.ts deleted file mode 100644 index 66115e90689..00000000000 --- a/packages/agent-core-v2/src/agent/rpc/rpc.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createDecorator } from "#/_base/di/instantiation"; -import type { - AgentAPI, - SessionAPI, -} from './core-api'; -import type { PromisableMethods } from "#/_base/utils/types"; - -export interface IAgentRPCService extends PromisableMethods { - readonly _serviceBrand: undefined; -} - -export interface ISessionRPCService extends PromisableMethods { - readonly _serviceBrand: undefined; -} - -export const IAgentRPCService = - createDecorator('agentRPCService'); - -export const ISessionRPCService = - createDecorator('agentSessionRPCService'); diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts deleted file mode 100644 index f3d089ab76f..00000000000 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; -import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; -import { IEventBus } from '#/app/event/eventBus'; -import { IEventService } from '#/app/event/event'; -import { ErrorCodes, Error2 } from '#/errors'; -import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { - IAgentLifecycleService, - MAIN_AGENT_ID, -} from '#/session/agentLifecycle/agentLifecycle'; -import { IAgentCommandService } from '#/agent/command/agentCommand'; -import { expandCommandArguments } from '#/app/plugin/commands'; -import { IPluginService } from '#/app/plugin/plugin'; -import { ProfileError } from '#/agent/profile/profile'; -import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; -import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentConversationUndoService } from '#/agent/undo/undo'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { IAgentSkillService } from '#/agent/skill/skill'; -import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import type { - ActivatePluginCommandPayload, - ActivateSkillPayload, - CancelPayload, - EmptyPayload, - PromptLaunchResult, - PromptPayload, - RunCommandPayload, - SetPermissionPayload, - SteerPayload, - UndoHistoryPayload, -} from './core-api'; -import { IAgentRPCService } from './rpc'; -import { - applyPromptMetadataUpdate, - promptMetadataTextFromPayload, - promptMetadataTextFromPluginCommand, - promptMetadataTextFromSkill, -} from './prompt-metadata'; - -export interface PluginCommandActivatedEvent { - readonly type: 'plugin_command.activated'; - readonly activationId: string; - readonly pluginId: string; - readonly commandName: string; - readonly commandArgs?: string; - readonly trigger: 'user-slash'; -} - -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'plugin_command.activated': PluginCommandActivatedEvent; - } -} - -export class AgentRPCService implements IAgentRPCService { - declare readonly _serviceBrand: undefined; - - constructor( - @IAgentPromptService private readonly promptService: IAgentPromptService, - @IAgentConversationUndoService - private readonly conversationUndo: IAgentConversationUndoService, - @IAgentLoopService private readonly loop: IAgentLoopService, - @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, - @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, - @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, - @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, - @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentTokenCountingService private readonly tokenCounting: IAgentTokenCountingService, - @IAgentSkillService private readonly skills: IAgentSkillService, - @ITelemetryService private readonly telemetry: ITelemetryService, - @IEventBus private readonly eventBus: IEventBus, - @IEventService private readonly eventService: IEventService, - @IPluginService private readonly plugins: IPluginService, - @ISessionMetadata private readonly metadata: ISessionMetadata, - @ISessionContext private readonly sessionContext: ISessionContext, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, - @IAgentCommandService private readonly commands: IAgentCommandService, - ) { } - - async prompt(payload: PromptPayload): Promise { - if (payload.disabledTools !== undefined) { - try { - await this.toolPolicy.setSessionDisabledTools(payload.disabledTools); - } catch (error) { - if (error instanceof ProfileError) { - throw new Error2(ErrorCodes.REQUEST_INVALID, error.message); - } - throw error; - } - } - await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); - const handle = await this.promptService.enqueue({ message: { - role: 'user', - content: [...payload.input], - toolCalls: [], - origin: { kind: 'user' }, - } }); - if (handle.state === 'pending') return undefined; - const turn = await handle.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; - } - - async steer(payload: SteerPayload): Promise { - this.telemetry.track2('input_steer', { parts: payload.input.length }); - const queued = await this.promptService.enqueue({ message: { - role: 'user', - content: [...payload.input], - toolCalls: [], - } }); - const [steered] = await this.promptService.steer([queued.id]); - const turn = await steered?.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; - } - - cancel({ turnId }: CancelPayload): void { - if (this.loop.status().state === 'running') { - this.telemetry.track2('cancel', { - from: 'streaming', - trace_id: this.loop.status().activeTraceId, - }); - } - this.loop.cancel(turnId); - } - - async undoHistory(payload: UndoHistoryPayload): Promise { - return this.conversationUndo.undo(payload.count); - } - - setPermission(payload: SetPermissionPayload): void { - const wasYolo = this.permissionMode.mode === 'yolo'; - const wasAuto = this.permissionMode.mode === 'auto'; - this.permissionMode.setMode(payload.mode); - if (this.scopeContext.agentId === MAIN_AGENT_ID) { - this.agentLifecycle.broadcastPermissionMode(payload.mode); - } - const enabled = this.permissionMode.mode === 'yolo'; - if (enabled !== wasYolo) { - this.telemetry.track2('yolo_toggle', { enabled }); - } - const afkEnabled = this.permissionMode.mode === 'auto'; - if (afkEnabled !== wasAuto) { - this.telemetry.track2('afk_toggle', { enabled: afkEnabled }); - } - } - - cancelCompaction(_payload: EmptyPayload): void { - const active = this.fullCompaction.compacting; - if (active !== null) { - this.telemetry.track2('cancel', { - from: 'compacting', - trace_id: active.traceId, - }); - } - active?.abortController.abort(); - } - - async activateSkill(payload: ActivateSkillPayload): Promise { - // Awaited (not fire-and-forget): the caller gets the launched turn id and - // activation failures (unknown skill, busy) surface instead of vanishing. - const turn = await this.skills.activate(payload); - await this.updatePromptMetadata(promptMetadataTextFromSkill(payload)); - return { turn_id: turn.id }; - } - - async activatePluginCommand(payload: ActivatePluginCommandPayload): Promise { - const commands = await this.plugins.listPluginCommands(); - const def = commands.find( - (command) => command.pluginId === payload.pluginId && command.name === payload.commandName, - ); - if (def === undefined) { - throw new Error2( - ErrorCodes.REQUEST_INVALID, - `Plugin command "${payload.pluginId}:${payload.commandName}" was not found`, - ); - } - const commandArgs = payload.args ?? ''; - const expanded = expandCommandArguments(def.body, commandArgs); - const origin = { - kind: 'plugin_command' as const, - activationId: randomUUID(), - pluginId: payload.pluginId, - commandName: payload.commandName, - commandArgs: payload.args, - trigger: 'user-slash' as const, - }; - this.eventBus.publish({ - type: 'plugin_command.activated', - activationId: origin.activationId, - pluginId: origin.pluginId, - commandName: origin.commandName, - commandArgs: origin.commandArgs, - trigger: origin.trigger, - }); - await this.promptService.enqueue({ message: { - role: 'user', - content: [{ type: 'text', text: expanded }], - toolCalls: [], - origin, - } }); - await this.updatePromptMetadata(promptMetadataTextFromPluginCommand(payload)); - } - - private async updatePromptMetadata(text: string | undefined): Promise { - await applyPromptMetadataUpdate( - { - metadata: this.metadata, - eventService: this.eventService, - sessionId: this.sessionContext.sessionId, - }, - text, - ); - } - - getContext(_payload: EmptyPayload) { - return { - history: this.context.get(), - // The externally reported context size, resolved by the - // `[token_counting]` strategy inside the service — matching the v1 - // `context.tokenCount` semantics. - tokenCount: this.tokenCounting.statusSize(), - }; - } - - listCommands(_payload: EmptyPayload) { - return this.commands.list(); - } - - async runCommand(payload: RunCommandPayload): Promise { - return this.commands.run(payload.name, payload.args); - } - - getTools(_payload: EmptyPayload) { - return this.toolRegistry.list().map((tool) => ({ - name: tool.name, - description: tool.description, - active: this.toolPolicy.isToolActive(tool.name, tool.source), - source: tool.source, - })); - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentRPCService, - AgentRPCService, - ScopeActivation.OnScopeCreated, - 'rpc', -); diff --git a/packages/agent-core-v2/src/agent/rpc/types.ts b/packages/agent-core-v2/src/agent/rpc/types.ts deleted file mode 100644 index fb661f597a7..00000000000 --- a/packages/agent-core-v2/src/agent/rpc/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * `rpc` domain (L8) — shared request wrapper types. - */ - -export type WithSessionId = T & { - readonly sessionId: string; -}; - -export type WithAgentId = T & { - readonly agentId: string; -}; diff --git a/packages/agent-core-v2/src/agent/skill/prompt.ts b/packages/agent-core-v2/src/agent/skill/prompt.ts index 1cbb50362d5..4cffd8522c6 100644 --- a/packages/agent-core-v2/src/agent/skill/prompt.ts +++ b/packages/agent-core-v2/src/agent/skill/prompt.ts @@ -1,6 +1,16 @@ import { escapeXml } from '#/_base/utils/xml-escape'; +import { promptMetadataTextFromText } from '#/agent/prompt/promptMetadataText'; import type { SkillSource } from '#/app/skillCatalog/types'; +import type { SkillActivationInput } from './skill'; + +export function promptMetadataTextFromSkill(input: SkillActivationInput): string | undefined { + const args = input.args?.trim(); + return promptMetadataTextFromText( + args === undefined || args.length === 0 ? `/${input.name}` : `/${input.name} ${args}`, + ); +} + export type SkillPromptTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; export interface RenderSkillPromptInput { diff --git a/packages/agent-core-v2/src/agent/skill/skill.ts b/packages/agent-core-v2/src/agent/skill/skill.ts index ed4eb3e93e9..e512195a945 100644 --- a/packages/agent-core-v2/src/agent/skill/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/skill.ts @@ -10,7 +10,7 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { SkillActivationOrigin } from '#/agent/contextMemory/types'; -import type { Turn } from '#/agent/loop/loop'; +import type { PromptLaunchResult } from '#/agent/prompt/prompt'; import type { ContentPart } from '#/kosong/contract/message'; export interface SkillActivationInput { @@ -22,7 +22,7 @@ export interface SkillActivationInput { export interface IAgentSkillService { readonly _serviceBrand: undefined; - activate(input: SkillActivationInput): Promise; + activate(input: SkillActivationInput): Promise; recordModelToolActivation(origin: SkillActivationOrigin): void; } diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index aed7efba28e..6148e52268c 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -6,10 +6,12 @@ * (a stateless, identity-apply Op), derives the `skill.activated` event * through the Op's `toEvent`, drives user-slash activations into a new turn via * `prompt` (attachment parts from the caller ride the same user message after - * the rendered prompt), and reports `skill_invoked` / `flow_invoked` through - * `telemetry`. `wire.replay` reapplies the fact as a no-op, so neither the - * event nor telemetry fires on resume (matching the former `restoring` guard). - * Bound at Agent scope. + * the rendered prompt), settles `{turn_id}` for the caller, persists the + * derived title/lastPrompt through `sessionMetadata` for the main agent only + * (publishing the live update through `event`), and reports `skill_invoked` / + * `flow_invoked` through `telemetry`. `wire.replay` reapplies the fact as a + * no-op, so neither the event nor telemetry fires on resume (matching the + * former `restoring` guard). Bound at Agent scope. */ import { randomUUID } from 'node:crypto'; @@ -19,18 +21,23 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#/kosong/contract/message'; import type { ContextMessage, SkillActivationOrigin } from '#/agent/contextMemory/types'; -import { renderUserSlashSkillPrompt } from './prompt'; +import { promptMetadataTextFromSkill, renderUserSlashSkillPrompt } from './prompt'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { Service } from '#/_base/di/service'; import { ErrorCodes, Error2 } from '#/errors'; import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types'; -import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentPromptService, type PromptLaunchResult } from '#/agent/prompt/prompt'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { Turn } from '#/agent/loop/loop'; import { IWireService } from '#/wire/wire'; import { IAgentSkillService, type SkillActivationInput } from './skill'; import { skillActivate } from './skillOps'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { IEventService } from '#/app/event/event'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { applyPromptMetadataUpdate } from '#/session/sessionMetadata/promptMetadata'; export class AgentSkillService extends Service implements IAgentSkillService { declare readonly _serviceBrand: undefined; @@ -41,11 +48,14 @@ export class AgentSkillService extends Service implements IAgentSkillService { @IWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionContext private readonly sessionContext: ISessionContext, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { super(); } - async activate(input: SkillActivationInput): Promise { + async activate(input: SkillActivationInput): Promise { await this.skillCatalog.ready; const skill = this.skillCatalog.catalog.getSkill(input.name); if (skill === undefined) { @@ -93,7 +103,19 @@ export class AgentSkillService extends Service implements IAgentSkillService { 'Cannot activate skill while another turn is active', ); } - return turn; + // Awaited (not fire-and-forget): the caller gets the launched turn id and + // activation failures (unknown skill, busy) surface instead of vanishing. + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + await applyPromptMetadataUpdate( + { + metadata: this.metadata, + eventService: this.eventService, + sessionId: this.sessionContext.sessionId, + }, + promptMetadataTextFromSkill(input), + ); + } + return { turn_id: turn.id }; } recordModelToolActivation(origin: SkillActivationOrigin): void { diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts index 3ecf30eaed2..106c2de2a1a 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts @@ -1,7 +1,32 @@ +/** + * `systemReminder` domain — low-level model-facing reminder write contract. + * + * Defines the Agent-scoped write head used by context injection, event-point + * one-off reminders, and prompt-owned media annotations, and owns the + * `` text format: `wrapSystemReminder` is the only writer, + * `systemReminderContent` the only reader, so no consumer reconstructs the + * format by hand. Bound at Agent scope. + */ + import { createDecorator } from "#/_base/di/instantiation"; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; +const SYSTEM_REMINDER_PREFIX = '\n'; +const SYSTEM_REMINDER_SUFFIX = '\n'; + +export function wrapSystemReminder(content: string): string { + return `${SYSTEM_REMINDER_PREFIX}${content.trim()}${SYSTEM_REMINDER_SUFFIX}`; +} + +export function systemReminderContent(message: ContextMessage): string | undefined { + const text = message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + if (!text.startsWith(SYSTEM_REMINDER_PREFIX) || !text.endsWith(SYSTEM_REMINDER_SUFFIX)) { + return undefined; + } + return text.slice(SYSTEM_REMINDER_PREFIX.length, text.length - SYSTEM_REMINDER_SUFFIX.length); +} + export interface IAgentSystemReminderService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts index 317fa17a9c6..e2cf5d37d71 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts @@ -1,10 +1,17 @@ -import { Service } from "#/_base/di/service"; +/** + * `systemReminder` domain — `IAgentSystemReminderService` implementation. + * + * Appends model-facing reminder messages, wrapped by `wrapSystemReminder`, + * into the conversation through `contextMemory`. Bound at Agent scope. + */ + +import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; -import { IAgentSystemReminderService } from './systemReminder'; +import { IAgentSystemReminderService, wrapSystemReminder } from './systemReminder'; export class AgentSystemReminderService extends Service implements IAgentSystemReminderService { declare readonly _serviceBrand: undefined; @@ -21,7 +28,7 @@ export class AgentSystemReminderService extends Service implements IAgentSystemR content: [ { type: 'text', - text: `\n${content.trim()}\n`, + text: wrapSystemReminder(content), }, ], toolCalls: [], diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 617336bd9a0..0418dc571b5 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -28,35 +28,39 @@ import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentStateService } from '#/agent/state/agentState'; +import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/kosong/contract/message'; import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; const REMINDER_TEXT_1 = - '\n\n\n' + - 'The same tool call has been repeated several times in a row. ' + - 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + - 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.' + - '\n'; + '\n\n' + + wrapSystemReminder( + 'The same tool call has been repeated several times in a row. ' + + 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + + 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.', + ); function makeReminderText2(repeatCount: number): string { return ( - '\n\n\n' + - `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + - 'Choose exactly one of the following and state your choice before acting:\n' + - '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + - '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + - '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.' + - '\n' + '\n\n' + + wrapSystemReminder( + `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + + 'Choose exactly one of the following and state your choice before acting:\n' + + '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + + '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + + '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.', + ) ); } const REMINDER_TEXT_3 = - '\n\n\n' + - 'Write your final response now, without any further tool calls. ' + - 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + - 'Text only.' + - '\n'; + '\n\n' + + wrapSystemReminder( + 'Write your final response now, without any further tool calls. ' + + 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + + 'Text only.', + ); const REPEAT_REMINDER_1_START = 3; const REPEAT_REMINDER_2_START = 5; diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 8f568065097..f5e68826ae3 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -72,7 +72,10 @@ const ABORT_GRACE_MS = 2_000; const TOOL_OUTPUT_EMPTY = 'Tool output is empty.'; const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.'; -const validators = new WeakMap(); +const validators = new WeakMap< + ExecutableTool, + { schema: Record; validator: ToolArgsValidator } +>(); export interface ToolExecutionTask { readonly accesses: ToolAccesses; @@ -793,16 +796,17 @@ function preflightToolCall( } function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null { - let validator = validators.get(tool); - if (validator === undefined) { + const schema = tool.parameters; + let cached = validators.get(tool); + if (cached === undefined || cached.schema !== schema) { try { - validator = compileToolArgsValidator(tool.parameters); - validators.set(tool, validator); + cached = { schema, validator: compileToolArgsValidator(schema) }; + validators.set(tool, cached); } catch (error) { return error instanceof Error ? error.message : String(error); } } - return validateToolArgs(validator, args as JsonType); + return validateToolArgs(cached.validator, args as JsonType); } function toolCallDisplayFieldsFromExecution( diff --git a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts index b3535f3990c..483bf9ba314 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts @@ -14,9 +14,10 @@ * first real user prompt it finds regardless of origin: schema messages * survive only when the cut lands before them. * - loadable-tools announcements: `/` system - * reminders (origin `{kind: 'system_trigger', name: 'loadable-tools'}`) — - * undo removes them (they are not `injection`-origin), and the next - * turn-boundary diff self-heals by re-announcing the folded delta. + * reminders (origin `{kind: 'injection', variant: 'loadable-tools'}`; + * legacy journals used `{kind: 'system_trigger', name: 'loadable-tools'}` + * and both are folded) — the next turn-boundary diff self-heals by + * re-announcing the folded delta whenever the ledger drifts. * * The loaded-tool ledger is the history itself: there is deliberately no * separate persisted ledger, so undo/compaction/resume all self-heal by @@ -29,17 +30,16 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; export const DYNAMIC_TOOL_SCHEMA_VARIANT = 'dynamic_tool_schema'; -export const LOADABLE_TOOLS_TRIGGER = 'loadable-tools'; +export const LOADABLE_TOOLS_VARIANT = 'loadable-tools'; export function isDynamicToolSchemaMessage(message: ContextMessage): boolean { return message.tools !== undefined && message.tools.length > 0; } export function isLoadableToolsAnnouncement(message: ContextMessage): boolean { - return ( - message.origin?.kind === 'system_trigger' && - message.origin.name === LOADABLE_TOOLS_TRIGGER - ); + const origin = message.origin; + if (origin?.kind === 'injection') return origin.variant === LOADABLE_TOOLS_VARIANT; + return origin?.kind === 'system_trigger' && origin.name === LOADABLE_TOOLS_VARIANT; } export function stripDynamicToolContext( diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts index e3ce761aba9..b58f3d601f4 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts @@ -2,12 +2,13 @@ * `toolSelect` domain — progressive tool disclosure contract. * * Defines the Agent-scope service that shapes provider-visible tool/history - * views, loads selected dynamic schemas, and reports loadable-tool - * announcements. + * views, records selected dynamic schemas as pending declarations, and + * reports loadable-tool announcements. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { Tool } from '#/kosong/contract/tool'; import type { ToolInfo } from '#/tool/toolContract'; export const SELECT_TOOLS_TOOL_NAME = 'select_tools'; @@ -33,6 +34,8 @@ export interface IAgentToolSelectService { load(names: readonly string[]): LoadToolsResult; + drainPendingToolSchemas(): readonly Tool[] | undefined; + loadableToolsAnnouncement(): string | undefined; } diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts index 6be3c1f1ac1..7a80fd17db0 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts @@ -1,8 +1,8 @@ /** * `toolSelect` domain — `IAgentToolSelectAnnouncementsService` contract. * - * Defines the Agent-scope marker service that appends v1-compatible - * loadable-tools announcements through `systemReminder` at loop boundaries. + * Defines the Agent-scope marker service that announces v1-compatible + * loadable-tools diffs through the `contextInjector` boundary scheduler. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts index 3338fbdc22c..94e32f8b104 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts @@ -2,75 +2,37 @@ * `toolSelect` domain — `IAgentToolSelectAnnouncementsService` * implementation. * - * Appends v1-compatible loadable-tools diff announcements at turn boundaries - * through `systemReminder`, hooks into `loop` before each step, reads - * announcement text from `IAgentToolSelectService`, and observes compaction - * boundaries from `event`. Turn boundaries need no state: every turn starts - * at loop step 1, which always evaluates injection. The compaction-boundary - * flag (`needsBoundaryInjection`) is registered into `agentState` - * (`IAgentStateService`) and read/written through it. Bound at Agent scope. + * Registers v1-compatible loadable-tools diff announcements as a + * `contextInjector` provider (variant `loadable-tools`). The injector's + * `isNewTurn` covers exactly the old boundary set — every turn's first step + * and the post-compaction inject — so no local boundary state is needed. + * Reads announcement text from `IAgentToolSelectService`; the folded history + * itself remains the ledger, so undo/compaction/resume all self-heal by + * re-folding. Bound at Agent scope. */ import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; -import { IEventBus } from '#/app/event/eventBus'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; -import { LOADABLE_TOOLS_TRIGGER } from './dynamicTools'; +import { LOADABLE_TOOLS_VARIANT } from './dynamicTools'; import { IAgentToolSelectService } from './toolSelect'; import { IAgentToolSelectAnnouncementsService } from './toolSelectAnnouncements'; -export const toolSelectNeedsBoundaryInjectionKey = defineState( - 'toolSelect.needsBoundaryInjection', - () => false, -); - export class AgentToolSelectAnnouncementsService extends Service implements IAgentToolSelectAnnouncementsService { declare readonly _serviceBrand: undefined; constructor( @IAgentToolSelectService toolSelect: IAgentToolSelectService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IEventBus eventBus: IEventBus, - @IAgentLoopService loopService: IAgentLoopService, - @IAgentStateService private readonly states: IAgentStateService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); - this.states.register(toolSelectNeedsBoundaryInjectionKey); this._register( - eventBus.subscribe('compaction.completed', () => { - this.needsBoundaryInjection = true; - }), + injector.register(LOADABLE_TOOLS_VARIANT, ({ isNewTurn }) => + isNewTurn ? toolSelect.loadableToolsAnnouncement() : undefined, + ), ); - this._register( - loopService.hooks.onWillBeginStep.register('toolSelectAnnouncements', async (ctx, next) => { - await next(); - if (ctx.step !== 1 && !this.needsBoundaryInjection) return; - this.needsBoundaryInjection = false; - this.inject(toolSelect); - }), - ); - } - - private get needsBoundaryInjection(): boolean { - return this.states.get(toolSelectNeedsBoundaryInjectionKey); - } - - private set needsBoundaryInjection(value: boolean) { - this.states.set(toolSelectNeedsBoundaryInjectionKey, value); - } - - private inject(toolSelect: IAgentToolSelectService): void { - const announcement = toolSelect.loadableToolsAnnouncement(); - if (announcement === undefined) return; - this.reminders.appendSystemReminder(announcement, { - kind: 'system_trigger', - name: LOADABLE_TOOLS_TRIGGER, - }); } } diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts new file mode 100644 index 00000000000..2cbaf96d0b9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts @@ -0,0 +1,15 @@ +/** + * `toolSelect` domain — `IAgentToolSelectSchemasService` contract. + * + * Defines the Agent-scope marker service that declares pending dynamic-tool + * schemas into the history through the `contextInjector` boundary scheduler. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentToolSelectSchemasService { + readonly _serviceBrand: undefined; +} + +export const IAgentToolSelectSchemasService: ServiceIdentifier = + createDecorator('agentToolSelectSchemasService'); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts new file mode 100644 index 00000000000..b491164ec28 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts @@ -0,0 +1,41 @@ +/** + * `toolSelect` domain — `IAgentToolSelectSchemasService` implementation. + * + * Declares pending dynamic-tool schemas from `toolSelect` through + * `contextInjector`. Bound at Agent scope. + */ + +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; + +import { DYNAMIC_TOOL_SCHEMA_VARIANT } from './dynamicTools'; +import { IAgentToolSelectService } from './toolSelect'; +import { IAgentToolSelectSchemasService } from './toolSelectSchemas'; + +export class AgentToolSelectSchemasService extends Service implements IAgentToolSelectSchemasService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentToolSelectService toolSelect: IAgentToolSelectService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, + ) { + super(); + this._register( + injector.register(DYNAMIC_TOOL_SCHEMA_VARIANT, () => { + const tools = toolSelect.drainPendingToolSchemas(); + if (tools === undefined) return undefined; + return { message: { role: 'system', content: [], tools } }; + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolSelectSchemasService, + AgentToolSelectSchemasService, + ScopeActivation.OnScopeCreated, + 'toolSelect', +); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index fa4c3489eb9..5d936f5420f 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -2,13 +2,18 @@ * `toolSelect` domain — `IAgentToolSelectService` implementation. * * Shapes the provider-visible tool and history views for progressive tool - * disclosure, loads dynamic schemas into `contextMemory`, and exposes - * loadable-tools announcement text. Reads live tools from `toolRegistry`, - * active-tool and capability state from `profile`, gates through `flag`, - * hooks into `toolExecutor`, and listens to context lifecycle events through - * `event`. The mutable load-tracking state (`pendingLoaded`) is registered - * into `agentState` (`IAgentStateService`) and read/written through it. Bound - * at Agent scope. + * disclosure, tracks loaded dynamic schemas as pending declarations drained + * by the `contextInjector` boundary provider (the declaration lands at a + * quiescent boundary instead of mid-step inside a streaming tool exchange), + * and exposes loadable-tools announcement text. Removal splices + * (`undo`/`clear`) drop pending entries whose announcing exchange left the + * conversation, while compaction's replacement splice keeps them, so the + * declaration still lands at the post-compaction boundary. Reads live tools from + * `toolRegistry`, active-tool and capability state from `profile`, gates + * through `flag`, hooks into `toolExecutor`, and listens to context + * lifecycle events through `event`. The mutable load-tracking state + * (`pendingLoaded`) is registered into `agentState` (`IAgentStateService`) + * and read/written through it. Bound at Agent scope. */ import { Service } from '#/_base/di/service'; @@ -29,7 +34,6 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { collectLoadedDynamicToolNames, - DYNAMIC_TOOL_SCHEMA_VARIANT, foldAnnouncedToolNames, renderLoadableToolsAnnouncement, stripDynamicToolContext, @@ -75,7 +79,7 @@ export class AgentToolSelectService extends Service implements IAgentToolSelectS ); this._register( eventBus.subscribe('context.spliced', (splice) => { - if (splice.deleteCount === 0 || this.pendingLoaded.size === 0) return; + if (splice.deleteCount === 0 || splice.messages.length > 0) return; this.dropPendingLoadedNotLanded(); }), ); @@ -144,22 +148,24 @@ export class AgentToolSelectService extends Service implements IAgentToolSelectS } } if (toLoad.length > 0) { - toLoad.sort((a, b) => a.localeCompare(b)); - const tools = toLoad - .map((name) => this.schemaOf(name)) - .filter((tool): tool is Tool => tool !== undefined); - this.context.append({ - role: 'system', - content: [], - toolCalls: [], - tools, - origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT }, - }); for (const name of toLoad) this.pendingLoaded.add(name); } return { toLoad, alreadyAvailable, unknown }; } + drainPendingToolSchemas(): readonly Tool[] | undefined { + if (!this.enabled() || this.pendingLoaded.size === 0) return undefined; + const names = [...this.pendingLoaded].toSorted((a, b) => a.localeCompare(b)); + const tools: Tool[] = []; + for (const name of names) { + const tool = this.schemaOf(name); + if (tool === undefined) continue; + this.pendingLoaded.delete(name); + tools.push(tool); + } + return tools.length === 0 ? undefined : tools; + } + loadableToolsAnnouncement(): string | undefined { if (!this.enabled()) return undefined; const loadable = this.loadableToolNames(); diff --git a/packages/agent-core-v2/src/agent/tools/agent/agent.ts b/packages/agent-core-v2/src/agent/tools/agent/agent.ts index 7bc0bda0f6f..d1025ff21c4 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agent.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agent.ts @@ -56,10 +56,10 @@ export const SubagentToolInputSchema = z.preprocess( 'If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.', ), model: z - .enum(['secondary', 'primary']) + .string() .optional() .describe( - 'Which model to run the subagent on: "secondary" = the configured secondary model; "primary" = the main model you are running on (for hard, quality-sensitive tasks). This explicit choice overrides the selected agent type\'s model_preference; without either, secondary is the default when configured. Only effective when a secondary model is configured; otherwise the subagent inherits your model. Ignored when resuming — resumed subagents keep their own model.', + 'Which model to run the subagent on: one of the aliases listed under "Available models" in this tool description, or "primary" for the main model you are running on (for hard, quality-sensitive tasks). When omitted, the configured default model is used. Ignored when resuming — resumed subagents keep their own model.', ), }), ); diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 330549a1f5c..7401a36283d 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -10,9 +10,14 @@ * under TaskList/TaskOutput/TaskStop when `run_in_background=true` or after * detach), and terminal text formatting. * - * Spawn bindings use an explicit tool choice first, then the target profile's - * symbolic model preference, before `resolveSubagentBinding` falls back to the - * configured secondary model or the caller's model. The selected alias is + * Spawn bindings use the explicit tool `model` choice first, before + * `resolveSubagentBinding` falls back to the configured `[secondary_model.models]` + * pool default or the caller's model; with `[secondary_model].force` set the + * `model` parameter is not advertised and every spawn binds `default_model`. + * The pool is gated behind the `secondary-model` experiment (via + * `IFlagService`): while it is off the `model` parameter is stripped and + * every spawn inherits the caller's model. + * The selected alias is * resolved through the model catalog before lifecycle allocation. A resumed * agent keeps the model recorded in its own wire journal — with per-subagent * models there is no "child follows the parent's current model" invariant to @@ -21,9 +26,10 @@ * Registered via the module-level `registerAgentToolService(ISubagentTool, * SubagentTool)` at the bottom of this file — the same "import = register" * pattern used by every agent tool. The per-profile tool listings in the - * description read the full contribution table (not the runtime registry, - * which only holds tools the caller's own Profile activated), plus any - * dynamically registered tools. The description's catalog profile list is + * description read the full `AgentToolContribution` collection — static + * registrations and feature-contributed tools alike — not the runtime + * registry, which only holds tools the caller's own Profile activated, + * plus any dynamically registered tools. The description's catalog profile list is * snapshotted once the session catalog has loaded and frozen for the agent's * lifetime: plugin install / enable / disable / remove re-contributes * profiles mid-session, and a live read would rewrite the tools payload of @@ -32,6 +38,7 @@ * Bound at Agent scope. */ +import { type CollectionView } from '#/_base/di/collection'; import type { IAgentScopeHandle } from '#/_base/di/scope'; import { isAbortError, @@ -62,7 +69,7 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { - getAgentToolContributions, + AgentToolContribution, registerAgentToolService, } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService, type ToolReference } from '#/agent/toolRegistry/toolRegistry'; @@ -87,14 +94,13 @@ import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAg import { ISessionSubagentService } from '#/session/subagent/subagent'; import { buildSubagentModelDescriptions, + exposesSubagentModelChoice, formatSubagentTimeoutDescription, resolveSubagentBinding, resolveSubagentTimeoutMs, stripSubagentModelParameter, - subagentDisplayModel, wrapSubagentModelError, } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { BACKGROUND_AGENT_UNAVAILABLE, DEFAULT_PROFILE_NAME, @@ -120,7 +126,7 @@ export class SubagentTool implements ISubagentTool { readonly name: string = 'Agent'; get parameters(): Record { - return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) + return exposesSubagentModelChoice(this.config, this.flags) ? SUBAGENT_TOOL_PARAMETERS : SUBAGENT_TOOL_PARAMETERS_NO_MODEL; } @@ -147,6 +153,7 @@ export class SubagentTool implements ISubagentTool { @IConfigService private readonly config: IConfigService, @IFlagService private readonly flags: IFlagService, @IModelCatalog private readonly modelCatalog: IModelCatalog, + @AgentToolContribution private readonly contributions: CollectionView, ) { this.callerAgentId = scopeContext.agentId; this.canRunInBackground = () => @@ -174,7 +181,6 @@ export class SubagentTool implements ISubagentTool { this.knownToolReferences(), (profile, name, source) => this.toolPolicy.isToolActiveForProfile(profile, name, source), - this.flags.enabled(SECONDARY_MODEL_FLAG_ID), ); if (typeLines) { description += `\n\nAvailable agent types (pass via subagent_type):\n${typeLines}`; @@ -183,7 +189,6 @@ export class SubagentTool implements ISubagentTool { this.config, this.flags, this.profile.data().modelAlias, - this.modelCatalog, ); if (modelLines !== undefined) { description += `\n\n${modelLines}`; @@ -202,7 +207,7 @@ export class SubagentTool implements ISubagentTool { private knownToolReferences(): ToolReference[] { const refs = new Map(); - for (const contribution of getAgentToolContributions()) { + for (const contribution of this.contributions.items) { refs.set(contribution.options.name, { name: contribution.options.name, source: contribution.options.source ?? 'builtin', @@ -284,10 +289,7 @@ export class SubagentTool implements ISubagentTool { agentId = target.id; const resumed = target.accessor.get(IAgentProfileService).data(); profileName = resumed.profileName ?? RESUMED_LABEL; - displayModel = - resumed.modelAlias === undefined - ? undefined - : subagentDisplayModel(this.config, resumed.modelAlias); + displayModel = resumed.modelAlias; } else { const requestedProfileName = args.subagent_type?.length ? args.subagent_type @@ -317,7 +319,7 @@ export class SubagentTool implements ISubagentTool { this.config, this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, - args.model ?? profile.modelPreference, + args.model, ); let created: IAgentScopeHandle; try { @@ -339,7 +341,7 @@ export class SubagentTool implements ISubagentTool { .inheritUserTools(requester.accessor.get(IAgentUserToolService)); agentId = created.id; profileName = profile.name; - displayModel = binding.displayModel; + displayModel = binding.model; promptText = await applyProfilePromptPrefix(profile, args.prompt, { cwd: this.workspace.workDir, runner: this.processRunner, @@ -535,7 +537,6 @@ function buildProfileDescriptions( name: string, source: ToolReference['source'], ) => boolean, - showModelPreferences: boolean, ): string { return profiles .map((profile) => { @@ -543,10 +544,6 @@ function buildProfileDescriptions( (part): part is string => part !== undefined && part.length > 0, ); const header = details.length === 0 ? `- ${profile.name}` : `- ${profile.name}: ${details.join(' ')}`; - const headerLines = - !showModelPreferences || profile.modelPreference === undefined - ? header - : `${header}\n Model preference: ${profile.modelPreference}`; const activeTools = resolveActiveToolNames(profile); const externallyRestricted = tools.some( (tool) => @@ -558,20 +555,20 @@ function buildProfileDescriptions( .filter((tool) => isToolActive(profile, tool.name, tool.source)) .map((tool) => tool.name); if (effectiveTools.length === 0) { - return `${headerLines}\n Tools: none`; + return `${header}\n Tools: none`; } - return `${headerLines}\n Tools: ${effectiveTools.join(', ')}`; + return `${header}\n Tools: ${effectiveTools.join(', ')}`; } if (activeTools === undefined) { if ((profile.disallowedTools?.length ?? 0) > 0) { - return `${headerLines}\n Tools: all except ${profile.disallowedTools!.join(', ')}`; + return `${header}\n Tools: all except ${profile.disallowedTools!.join(', ')}`; } - return `${headerLines}\n Tools: all`; + return `${header}\n Tools: all`; } if (activeTools.length === 0) { - return `${headerLines}\n Tools: none`; + return `${header}\n Tools: none`; } - return `${headerLines}\n Tools: ${activeTools.join(', ')}`; + return `${header}\n Tools: ${activeTools.join(', ')}`; }) .join('\n'); } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 9f20446527a..048cd5e94fc 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -14,9 +14,7 @@ * `systemPrompt(context)` is the same render's text only — it is derived from * `renderSystemPrompt` at registration, so the two can never drift apart. * Profiles stay - * independent of concrete model aliases, but may declare - * a symbolic primary/secondary preference used as the default when spawned as - * a subagent. The builtin {@link DEFAULT_AGENT_PROFILE_NAME} (`agent`) is the + * independent of concrete model aliases. The builtin {@link DEFAULT_AGENT_PROFILE_NAME} (`agent`) is the * default profile used when an Agent is bound to a Model without naming a * profile. * @@ -43,8 +41,6 @@ import type { ISessionProcessRunner } from '#/session/process/processRunner'; export const DEFAULT_AGENT_PROFILE_NAME = 'agent'; -export type AgentModelPreference = 'primary' | 'secondary'; - export interface AgentProfilePromptPrefixContext { readonly cwd: string; readonly runner: ISessionProcessRunner; @@ -95,7 +91,6 @@ export interface AgentProfile { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; - readonly modelPreference?: AgentModelPreference; readonly systemPrompt: (context: AgentProfileContext) => string; readonly renderSystemPrompt: (context: AgentProfileContext) => SystemPromptRenderResult; readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index b80c1a00a02..f80aff2e1b9 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -155,7 +155,7 @@ export interface BootstrapResult { export function bootstrap(input: BootstrapInput, extraSeeds: ScopeSeed = []): BootstrapResult { const options = resolveBootstrapOptions(input); const app = createAppScope({ - extra: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds], + seeds: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds], }); return { app }; } diff --git a/packages/agent-core-v2/src/app/event/eventBusService.ts b/packages/agent-core-v2/src/app/event/eventBusService.ts index 959d31fdd2d..a9e6b26d1f1 100644 --- a/packages/agent-core-v2/src/app/event/eventBusService.ts +++ b/packages/agent-core-v2/src/app/event/eventBusService.ts @@ -21,7 +21,7 @@ import { type DomainEvent, type DomainEventMap, IEventBus } from './eventBus'; export class EventBusService extends Service implements IEventBus { declare readonly _serviceBrand: undefined; - private readonly allEmitter = this._register(new Emitter()); + private readonly allEmitter = this._register(new Emitter('*')); private readonly perType = new Map>(); publish(event: DomainEvent): void { @@ -29,6 +29,14 @@ export class EventBusService extends Service implements IEventBus { this.perType.get(event.type)?.fire(event); } + listenerCounts(): { all: number; perType: Record } { + const perType: Record = {}; + for (const [type, emitter] of this.perType) { + perType[String(type)] = emitter.listenerCount; + } + return { all: this.allEmitter.listenerCount, perType }; + } + subscribe(handler: (event: DomainEvent) => void): IDisposable; subscribe( type: K, @@ -44,7 +52,7 @@ export class EventBusService extends Service implements IEventBus { const type = typeOrHandler; let emitter = this.perType.get(type); if (emitter === undefined) { - emitter = this._register(new Emitter()); + emitter = this._register(new Emitter(String(type))); this.perType.set(type, emitter); } return emitter.event(handler as unknown as (event: DomainEvent) => void); diff --git a/packages/agent-core-v2/src/app/event/eventService.ts b/packages/agent-core-v2/src/app/event/eventService.ts index beafdbc8ca9..062a51e3e7a 100644 --- a/packages/agent-core-v2/src/app/event/eventService.ts +++ b/packages/agent-core-v2/src/app/event/eventService.ts @@ -16,9 +16,13 @@ import { type DomainEvent, IEventService } from './event'; export class EventService extends Service implements IEventService { declare readonly _serviceBrand: undefined; - private readonly emitter = this._register(new Emitter()); + private readonly emitter = this._register(new Emitter('publish')); readonly onDidPublish: Event = this.emitter.event; + get listenerCount(): number { + return this.emitter.listenerCount; + } + publish(event: DomainEvent): void { this.emitter.fire(event); } diff --git a/packages/agent-core-v2/src/app/feature/featureManager.ts b/packages/agent-core-v2/src/app/feature/featureManager.ts index 6694d6bb3bc..be2314df857 100644 --- a/packages/agent-core-v2/src/app/feature/featureManager.ts +++ b/packages/agent-core-v2/src/app/feature/featureManager.ts @@ -26,6 +26,7 @@ import type { ServiceRecipe, } from '#/_base/di/fiber'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ContributedFeatureService } from './featureServiceContribution'; export interface ManagedUnitInfo { readonly name: string; @@ -46,6 +47,7 @@ export interface IFeatureManager { updateUnit(name: string, config?: unknown): Promise; units(): readonly ManagedUnitInfo[]; + contributedServices(): readonly ContributedFeatureService[]; readonly onDidChangeUnits: Event; } diff --git a/packages/agent-core-v2/src/app/feature/featureManagerService.ts b/packages/agent-core-v2/src/app/feature/featureManagerService.ts index 7e521c72011..04e8e3e2d06 100644 --- a/packages/agent-core-v2/src/app/feature/featureManagerService.ts +++ b/packages/agent-core-v2/src/app/feature/featureManagerService.ts @@ -8,6 +8,7 @@ * the previous handle (retract-then-assemble is the caller's cascade). */ +import type { CollectionView } from '#/_base/di/collection'; import { Emitter, type Event } from '#/_base/event'; import type { FiberHandle, @@ -23,6 +24,10 @@ import { IFeatureManager, type ManagedUnitInfo, } from './featureManager'; +import { + FeatureServiceContribution, + type ContributedFeatureService, +} from './featureServiceContribution'; export class FeatureManagerService extends Service implements IFeatureManager { declare readonly _serviceBrand: undefined; @@ -31,7 +36,10 @@ export class FeatureManagerService extends Service implements IFeatureManager { private readonly _onDidChangeUnits = new Emitter(); readonly onDidChangeUnits: Event = this._onDidChangeUnits.event; - constructor() { + constructor( + @FeatureServiceContribution + private readonly _contributedServices: CollectionView, + ) { super(); this._register(this._onDidChangeUnits); } @@ -97,6 +105,10 @@ export class FeatureManagerService extends Service implements IFeatureManager { } return infos; } + + contributedServices(): readonly ContributedFeatureService[] { + return this._contributedServices.items; + } } registerScopedService( diff --git a/packages/agent-core-v2/src/app/feature/featureServiceContribution.ts b/packages/agent-core-v2/src/app/feature/featureServiceContribution.ts new file mode 100644 index 00000000000..635b152a4ee --- /dev/null +++ b/packages/agent-core-v2/src/app/feature/featureServiceContribution.ts @@ -0,0 +1,21 @@ +import { collection } from '#/_base/di/collection'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; +import type { LifecycleScope } from '#/app/scopes'; + +export interface ContributedFeatureService { + readonly scope: LifecycleScope; + readonly id: ServiceIdentifier; +} + +export const FeatureServiceContribution = collection( + 'feature-service', + { + validate(value, existing) { + if (existing.some((entry) => entry.scope === value.scope && entry.id === value.id)) { + throw new Error( + `Service ${String(value.id)} is already contributed at scope ${value.scope}`, + ); + } + }, + }, +); diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index 7af26196b34..68382257d5b 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -2,13 +2,13 @@ * `kosongConfig` domain — config-section declarations for kosong. * * The persistence wrapper for kosong's provider/model registries and the - * thinking / model-catalog / secondary-model preferences: declares every + * thinking / model-catalog preferences: declares every * kosong-owned section constant and its zod schema, plus the env bindings / * write-path strips and the snake_case ↔ camelCase TOML transforms. Where * kosong owns a pure type (`providers` / `models` / `thinking`), the schema * is re-derived from it and pinned by an `AssertExact` assertion (schema ≡ - * type at compile time); `modelCatalog` and `secondaryModel` have no - * kosong-side type — theirs derive from the local schemas. Self-registered + * type at compile time); `modelCatalog` has no + * kosong-side type — its derives from the local schema. Self-registered * at module load via `registerConfigSection`. * * `ProviderTypeSchema` is deliberately free-form text: vendor identity is @@ -25,7 +25,6 @@ import { z } from 'zod'; import { type ConfigStripEnv, envBindings, - stripEnvBoundFields, } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { @@ -311,33 +310,6 @@ registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { stripEnv: stripThinkingEnv, }); -export const SECONDARY_MODEL_SECTION = 'secondaryModel'; - -export const SECONDARY_MODEL_ENV = 'KIMI_SECONDARY_MODEL'; -export const SECONDARY_MODEL_EFFORT_ENV = 'KIMI_SECONDARY_EFFORT'; - -export const SecondaryModelConfigSchema = ModelOverrideSchema.extend({ - model: z.string().min(1).optional(), -}); - -export type SecondaryModelConfig = z.infer; - -function parseNonEmptyEnv(raw: string): string | undefined { - const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -export const secondaryModelEnvBindings = envBindings(SecondaryModelConfigSchema, { - model: { env: SECONDARY_MODEL_ENV, parse: parseNonEmptyEnv }, - defaultEffort: { env: SECONDARY_MODEL_EFFORT_ENV, parse: parseNonEmptyEnv }, -}); - -registerConfigSection(SECONDARY_MODEL_SECTION, SecondaryModelConfigSchema, { - env: secondaryModelEnvBindings, - stripEnv: stripEnvBoundFields(secondaryModelEnvBindings), -}); - - export const MODEL_CATALOG_SECTION = 'modelCatalog'; export const ModelCatalogConfigSchema = z.object({ diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index 76ffe7b5a40..390d49866f9 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -29,6 +29,10 @@ * registries therefore never pass through a halfway-removed state — that * intermediate state was the source of the "provider/model not * configured" startup race against profile binding. + * A write that replaces the models table also folds the + * `[secondary_model]` subagent pool through `cascadeSubagentModelPool` + * into the same transition, so a refresh that drops an alias can never + * leave a dangling pool for the session-start validation to trip on. * - The env-synthesized `__kimi_env__` slice is never written to config: * it lives in the effective overlay, and the bridge's event-driven sync * carries it into the registries on its own. `defaultModel` / `thinking` @@ -70,6 +74,11 @@ import { PROVIDERS_SECTION, THINKING_SECTION, } from './configSection'; +import { + SECONDARY_MODEL_SECTION, + cascadeSubagentModelPool, + type SecondaryModelConfig, +} from '#/session/subagent/configSection'; import { IProviderDiscoveryService, type RefreshProviderModelsOptions, @@ -254,6 +263,16 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { if ('thinking' in patch) { sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking; } + const nextModels = sections[MODELS_SECTION] as Record | undefined; + if (nextModels !== undefined) { + const cascadedPool = cascadeSubagentModelPool( + this.config.inspect(SECONDARY_MODEL_SECTION).userValue, + nextModels, + ); + if (cascadedPool !== undefined) { + sections[SECONDARY_MODEL_SECTION] = cascadedPool ?? undefined; + } + } await this.config.replaceSections(sections); return { providers: diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts index bf56aac7710..bd1c6653991 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts @@ -24,7 +24,11 @@ * `setDefined` drops those), and the models.dev import swaps aliases in two * passes (drop, then re-add onto clean slots). The kosong persistence * bridge then pushes the change into the registries, which is also what - * invalidates the runtime model catalog. + * invalidates the runtime model catalog. Each FINAL models-table pass also + * folds the `[secondary_model]` subagent pool through + * `cascadeSubagentModelPool` (the drop passes deliberately skip it — the + * re-add pass is what the pool must agree with), so an import that drops a + * pooled alias never leaves a dangling pool for session-start validation. * * Both third-party fetches — the models.dev directory and the custom-registry * import — send the identity snapshot's `outboundUserAgent`, matching what @@ -53,6 +57,11 @@ import { modelsDevProviderModels, resolveModelsDevImport } from './modelsDev'; import { DEFAULT_MODEL_SECTION, MODELS_SECTION, PROVIDERS_SECTION } from './configSection'; import { ModelsDevImportErrors } from './errors'; import { IKosongConfigService } from './kosongConfig'; +import { + SECONDARY_MODEL_SECTION, + cascadeSubagentModelPool, + type SecondaryModelConfig, +} from '#/session/subagent/configSection'; import { IModelsDevImportService, PROVIDER_ID_PATTERN, @@ -133,6 +142,19 @@ export class ModelsDevImportService implements IModelsDevImportService { return this.config; } + private async cascadePool( + config: IConfigService, + nextModels: Record, + ): Promise { + const cascaded = cascadeSubagentModelPool( + config.inspect(SECONDARY_MODEL_SECTION).userValue, + nextModels, + ); + if (cascaded !== undefined) { + await config.replace(SECONDARY_MODEL_SECTION, cascaded); + } + } + private async doImportModelsDevProvider( options: ImportModelsDevProviderOptions, ): Promise { @@ -201,6 +223,7 @@ export class ModelsDevImportService implements IModelsDevImportService { nextModels[`${targetId}/${model.id}`] = modelsDevModelToRecord(targetId, model); } await config.replace(MODELS_SECTION, nextModels); + await this.cascadePool(config, nextModels); const firstModel = models[0]; if (firstModel !== undefined) { @@ -289,6 +312,7 @@ export class ModelsDevImportService implements IModelsDevImportService { } await config.replace(PROVIDERS_SECTION, applied.providers as ProvidersSection); await config.replace(MODELS_SECTION, (applied.models ?? {}) as ModelsSection); + await this.cascadePool(config, applied.models ?? {}); const firstEntry = Object.values(entries)[0]; const firstModelKey = firstEntry === undefined ? undefined : Object.keys(firstEntry.models)[0]; diff --git a/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts deleted file mode 100644 index 899fc387f01..00000000000 --- a/packages/agent-core-v2/src/app/kosongConfig/secondaryModelOverlay.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * `kosongConfig` domain — `[secondary_model]` derived-entry overlay. - * - * When the secondary-model recipe carries patch fields, synthesizes the - * derived registry entry (`SECONDARY_DERIVED_MODEL_ID`) into the effective - * `models` view: a copy of the pointed entry with the patch merged into its - * `overrides` block (patch wins conflicts) and `aliases` dropped, so the - * derived entry never competes in name/alias routing. Subagent binding then - * resolves it by name through the standard catalog path, and the patch rides - * the same `effectiveModelConfig` merge as any `models.*.overrides` - * (including its supportEfforts/defaultEffort pruning and input clamping). - * - * Like the env overlay, the synthesized entry lives ONLY in the in-memory - * effective view: `strip` removes it from `models` writes so it never - * reaches `config.toml`, and the persistence bridge's deep-equal guards keep - * the two-way sync silent. `strip` also rolls back a `defaultModel` pointer - * set to the derived id (restoring the raw value, mirroring the env - * overlay's pinned-pointer handling) — the pointer can never dangle on disk - * after the recipe is removed. Nothing is synthesized when the recipe has no - * patch fields (subagents bind the pointed entry directly), when - * `secondary.model` is unset, or when the pointed entry does not exist (the - * warning service reports the dangling pointer; spawn fails with the wrapped - * error). The id is reserved: a user-configured entry under it is stripped - * on write all the same. - * - * Self-registered at module load via `registerConfigOverlay`; it is imported - * for side effects after the env overlay, so a `secondary.model` pointing at - * the env-synthesized entry sees the already-applied env view. - */ - -import type { ConfigEffectiveOverlay } from '#/app/config/config'; -import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; -import { isPlainObject } from '#/app/config/toml'; -import type { ModelOverride } from '#/kosong/model/model'; - -import { - DEFAULT_MODEL_SECTION, - MODELS_SECTION, - SECONDARY_MODEL_SECTION, - type SecondaryModelConfig, -} from './configSection'; - -export const SECONDARY_DERIVED_MODEL_ID = '__secondary__'; - -export function secondaryModelPatch( - secondary: SecondaryModelConfig | undefined, -): ModelOverride | undefined { - if (secondary === undefined) return undefined; - const { model: _model, ...patch } = secondary; - return Object.keys(patch).length > 0 ? patch : undefined; -} - -function asRecord(value: unknown): Record { - return isPlainObject(value) ? value : {}; -} - -function withoutKey(value: unknown, key: string): unknown { - if (!isPlainObject(value) || !(key in value)) return value; - const out: Record = { ...value }; - delete out[key]; - return out; -} - -export const secondaryModelOverlay: ConfigEffectiveOverlay = { - apply(effective, _getEnv, validate) { - const secondary = effective[SECONDARY_MODEL_SECTION] as SecondaryModelConfig | undefined; - const patch = secondaryModelPatch(secondary); - const baseId = secondary?.model; - if (patch === undefined || baseId === undefined || baseId === SECONDARY_DERIVED_MODEL_ID) { - return []; - } - const models = asRecord(effective[MODELS_SECTION]); - const base = models[baseId]; - if (!isPlainObject(base)) return []; - const { overrides: baseOverrides, aliases: _aliases, ...baseFields } = base; - const derived: Record = { - ...baseFields, - overrides: { ...asRecord(baseOverrides), ...patch }, - }; - effective[MODELS_SECTION] = validate(MODELS_SECTION, { - ...models, - [SECONDARY_DERIVED_MODEL_ID]: derived, - }); - return [MODELS_SECTION]; - }, - - strip(domain, value, rawSnake) { - switch (domain) { - case MODELS_SECTION: - return withoutKey(value, SECONDARY_DERIVED_MODEL_ID); - case DEFAULT_MODEL_SECTION: - if (value !== SECONDARY_DERIVED_MODEL_ID) return value; - return typeof rawSnake['default_model'] === 'string' - ? rawSnake['default_model'] - : undefined; - default: - return value; - } - }, -}; - -registerConfigOverlay(secondaryModelOverlay); diff --git a/packages/agent-core-v2/src/app/plugin/manager.ts b/packages/agent-core-v2/src/app/plugin/manager.ts index 2b6fae8549f..83bcb0dd284 100644 --- a/packages/agent-core-v2/src/app/plugin/manager.ts +++ b/packages/agent-core-v2/src/app/plugin/manager.ts @@ -320,6 +320,7 @@ export class PluginManager { path: dir, source: 'extra', plugin: { id: record.id, instructions: record.skillInstructions }, + scanMode: record.manifest.rootSkillFallback ? 'root-skill-only' : undefined, }); } } @@ -739,6 +740,7 @@ async function countDiscoveredPluginSkills( path: dir, source: 'extra', plugin: { id: pluginId, instructions: manifest?.skillInstructions }, + scanMode: manifest?.rootSkillFallback ? 'root-skill-only' : undefined, })); const result = await discoverSkills(roots); return result.skills.length; diff --git a/packages/agent-core-v2/src/app/plugin/manifest.ts b/packages/agent-core-v2/src/app/plugin/manifest.ts index 3a3a7bae0ba..8ceac789e4e 100644 --- a/packages/agent-core-v2/src/app/plugin/manifest.ts +++ b/packages/agent-core-v2/src/app/plugin/manifest.ts @@ -98,10 +98,12 @@ export async function parseManifest(pluginRoot: string): Promise>; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 10ddf32b60e..4a2aafd2d38 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -49,6 +49,9 @@ export interface SessionSummary { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + /** Archive time (epoch ms); absent for sessions archived before the field + * existed — callers fall back to `updatedAt` for display. */ + readonly archivedAt?: number; readonly custom?: Record; readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts index 9bd1d4f6fac..cd75ef1767a 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -66,6 +66,7 @@ export function buildSessionSummary(fields: { createdAt: number; updatedAt: number; archived: boolean; + archivedAt?: number; custom?: Record; lastTurnReason?: 'completed' | 'cancelled' | 'failed'; }): SessionSummary { @@ -78,6 +79,7 @@ export function buildSessionSummary(fields: { createdAt: fields.createdAt, updatedAt: fields.updatedAt, archived: fields.archived, + archivedAt: fields.archivedAt, custom: fields.custom, lastTurnReason: fields.lastTurnReason, }; @@ -108,6 +110,7 @@ export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean { a.createdAt === b.createdAt && a.updatedAt === b.updatedAt && a.archived === b.archived && + a.archivedAt === b.archivedAt && a.lastTurnReason === b.lastTurnReason && JSON.stringify(a.custom) === JSON.stringify(b.custom) ); @@ -159,6 +162,7 @@ export async function readSessionSummary( createdAt: parseTime(meta['createdAt']), updatedAt: parseTime(meta['updatedAt']), archived: meta['archived'] === true, + archivedAt: meta['archivedAt'] === undefined ? undefined : parseTime(meta['archivedAt']), custom, lastTurnReason: parseTurnOutcome(meta['lastTurnReason']), }); diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 1a6cffccdaa..9b5644763df 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -1,23 +1,25 @@ /** - * `sessionLegacy` domain (L7 edge adapter) — v1-compatible session actions. + * `sessionLegacy` domain (L7 edge adapter) — v1-compatible session reads. * - * Implements `POST /sessions/{id}/profile` (`updateProfile` — title rename, - * metadata merge, and the cross-domain `agent_config` patch), - * `GET /sessions/{id}/status` (`status`), and `GET /sessions/{id}/goal` - * (`goal`). The thin pass-through actions (`fork` / `compact` / `abort` / - * `archive`), the `:undo` action, and the `/sessions/{id}/children` endpoints - * are deliberately NOT wrapped here because none of them carries v1-only - * projection worth centralizing; only `updateProfile`, `status`, and `goal` - * stay in this adapter (the `agent_config` patch, the best-effort status - * rollup, and the current-goal read). Bound at App scope — it is a stateless - * dispatcher that resolves the target session/agent per call. + * Implements `GET /sessions/{id}/status` (`status` — the best-effort status + * rollup) and `GET /sessions/{id}/goal` (`goal` — the current-goal read), the + * two endpoints that hold real cross-domain adaptation. Everything else is + * deliberately NOT wrapped here: the thin pass-through actions (`fork` / + * `compact` / `abort` / `archive`), the `:undo` action, the + * `/sessions/{id}/children` endpoints, and `POST /sessions/{id}/profile` + * (title/metadata patch and `agent_config` dispatch) are plain wire-to-native + * translations composed by the kap-server routes directly. `SessionWireFields` + * stays exported here as the profile route's projection shape, consumed by the + * kap-server helper (`routes/sessionProfile.ts`) via deep-path import. Bound + * at App scope — it is a stateless dispatcher that resolves the target + * session/agent per call. */ import type { GoalSnapshot } from '#/agent/goal/types'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { SessionStatusResponse, UpdateSessionProfileRequest } from './sessionProtocol'; +import type { SessionStatusResponse } from './sessionProtocol'; export interface SessionWireFields { readonly id: string; @@ -28,13 +30,13 @@ export interface SessionWireFields { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly custom?: Record; } export interface ISessionLegacyService { readonly _serviceBrand: undefined; - updateProfile(sessionId: string, body: UpdateSessionProfileRequest): Promise; status(sessionId: string): Promise; goal(sessionId: string): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index f6df11b5c08..62c35299b94 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -3,14 +3,15 @@ * * Stateless App-scope dispatcher: each method resolves the target session (and * its main agent) per call, delegates to the native v2 services, and projects - * the result into the v1 wire shape. Only `updateProfile` (the cross-domain - * `agent_config` patch), `status` (the best-effort status rollup), and `goal` - * (the current-goal read) live here. No business logic is duplicated here. + * the result into the v1 wire shape. Only `status` (the best-effort status + * rollup) and `goal` (the current-goal read) live here — the profile route's + * title/metadata patch and `agent_config` dispatch are composed by kap-server. + * No business logic is duplicated here. */ import type { GoalSnapshot } from '#/agent/goal/types'; -import type { SessionStatusResponse, UpdateSessionProfileRequest } from './sessionProtocol'; +import type { SessionStatusResponse } from './sessionProtocol'; import { LifecycleScope } from '#/app/scopes'; import { type IAgentScopeHandle, @@ -25,10 +26,9 @@ import { import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { IAgentSupermoonService } from '#/agent/supermoon/supermoon'; import { getLiveSessionById, @@ -40,10 +40,8 @@ import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentActivityView } from '#/agent/activityView/activityView'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionLegacyService, type SessionWireFields } from './sessionLegacy'; +import { ISessionLegacyService } from './sessionLegacy'; export class SessionLegacyService implements ISessionLegacyService { declare readonly _serviceBrand: undefined; @@ -60,106 +58,6 @@ export class SessionLegacyService implements ISessionLegacyService { return resumeSessionById(this.services, sessionId); } - async updateProfile( - sessionId: string, - body: UpdateSessionProfileRequest, - ): Promise { - const session = await this.resume(sessionId); - if (session === undefined) { - throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); - } - const metadata = session.accessor.get(ISessionMetadata); - - if (typeof body.title === 'string') { - await metadata.setTitle(body.title); - } - - const metadataPatch = body.metadata; - if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) { - await metadata.update({ custom: { ...(metadataPatch as Record) } }); - } - - const agentConfig = body.agent_config; - if (agentConfig !== undefined) { - const agent = await this.resolveMainAgent(sessionId); - await this.applyAgentConfig(agent, agentConfig); - } - - const meta = await metadata.read(); - const ctx = session.accessor.get(ISessionContext); - return { - id: meta.id, - workspaceId: ctx.workspaceId, - root: ctx.cwd, - title: meta.title, - lastPrompt: meta.lastPrompt, - createdAt: meta.createdAt, - updatedAt: meta.updatedAt, - archived: meta.archived, - custom: meta.custom, - }; - } - - - private async applyAgentConfig( - agent: IAgentScopeHandle, - agentConfig: NonNullable, - ): Promise { - const profile = agent.accessor.get(IAgentProfileService); - if (agentConfig.model !== undefined && agentConfig.model !== '') { - await profile.setModel(agentConfig.model); - } - if (agentConfig.thinking !== undefined) { - profile.setThinking(agentConfig.thinking); - } - if (agentConfig.permission_mode !== undefined) { - agent.accessor - .get(IAgentLifecycleService) - .broadcastPermissionMode(agentConfig.permission_mode as PermissionMode); - } - if (agentConfig.plan_mode !== undefined) { - const plan = agent.accessor.get(IAgentPlanService); - const active = (await plan.status()) !== null; - if (active !== agentConfig.plan_mode) { - if (agentConfig.plan_mode) await plan.enter(); - else plan.exit(); - } - } - if (agentConfig.swarm_mode !== undefined) { - const swarm = agent.accessor.get(IAgentSwarmService); - if (swarm.isActive !== agentConfig.swarm_mode) { - if (agentConfig.swarm_mode) swarm.enter('manual'); - else swarm.exit(); - } - } - if (agentConfig.supermoon_mode !== undefined) { - const supermoon = agent.accessor.get(IAgentSupermoonService); - if (supermoon.isActive !== agentConfig.supermoon_mode) { - if (agentConfig.supermoon_mode) supermoon.enter('manual'); - else supermoon.exit(); - } - } - if (agentConfig.goal_objective !== undefined) { - await agent.accessor - .get(IAgentGoalService) - .createGoal({ objective: agentConfig.goal_objective }); - } - if (agentConfig.goal_control !== undefined) { - const goal = agent.accessor.get(IAgentGoalService); - switch (agentConfig.goal_control) { - case 'pause': - await goal.pauseGoal({}); - break; - case 'resume': - await goal.resumeGoal({ continueIfPaused: true, continueIfBlocked: true }); - break; - case 'cancel': - await goal.cancelGoal({}); - break; - } - } - } - private async resolveMainAgent(sessionId: string): Promise { const session = await this.resume(sessionId); if (session === undefined) { diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md index 15583877486..9fcdddb4b4e 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md @@ -20,7 +20,7 @@ echo "$HOME/.kimi-code" Use the first line when it is non-empty; otherwise use the second line. In the rest of this skill, `` means that resolved root — **never assume `~/.kimi-code`**. -- **`config.toml`** — agent / runtime settings: `default_model`, `secondary_model` (subagent model), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. +- **`config.toml`** — agent / runtime settings: `default_model`, `[secondary_model]` (experimental `secondary-model` flag: `default_model` / `[secondary_model.models]` subagent model pool / `force` to pin subagents to `default_model`; a lone legacy v1 `model` key is honored as a fallback default), `[subagent]` (`timeout_ms`), `providers`, `models`, `thinking`, `permission`, `hooks`, `loop_control`, etc. - **`tui.toml`** — terminal-UI / client preferences: `theme`, `[editor].command`, `[notifications]`, `[upgrade].auto_install` (auto-update). These can usually also be changed with the interactive commands `/config`, `/theme`, `/editor`, which is easier — prefer pointing the user at those. The "read → copy → Edit → validate → back up → overwrite" flow below applies to both files; only **which reload command applies** differs (see Capability 4). diff --git a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts index a73077ba897..78780f7d233 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts @@ -3,7 +3,10 @@ * * Discovers skill bundles by walking caller-supplied roots and parsing each * SKILL.md. Exposes discovery through the App-scoped service and a stateless - * filesystem entry point. + * filesystem entry point. A root whose `scanMode` is `root-skill-only` (the + * plugin manifest root SKILL.md fallback) is a single skill bundle: only its + * top-level SKILL.md is parsed, never sibling Markdown files or nested + * directories, so plugin docs like CHANGELOG.md are not mistaken for skills. */ import { promises as fs } from 'node:fs'; @@ -51,6 +54,21 @@ export async function discoverFileSkills( ): Promise { if (depth > MAX_SKILL_SCAN_DEPTH) return; + if (root.scanMode === 'root-skill-only') { + const rootSkillMd = path.join(dirPath, 'SKILL.md'); + if (await isFile(rootSkillMd)) { + await parseAndRegister({ + byDiscoveryKey, + skipped, + warn, + skillMdPath: rootSkillMd, + skillDirName: path.basename(dirPath), + root, + }); + } + return; + } + let entries: readonly string[]; try { entries = [...(await fs.readdir(dirPath))].toSorted(); diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index 8c9d4a5f710..367a33a6066 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -11,6 +11,8 @@ import { promises as fs } from 'node:fs'; import path from 'pathe'; +import { findUpwardRoot } from '#/_base/utils/paths'; + import type { SkillRoot, SkillSource } from './types'; const USER_BRAND_DIRS = ['skills'] as const; @@ -78,14 +80,7 @@ export async function configuredRoots( } async function findProjectRoot(workDir: string): Promise { - const start = path.resolve(workDir); - let current = start; - while (true) { - if (await exists(path.join(current, '.git'))) return current; - const parent = path.dirname(current); - if (parent === current) return start; - current = parent; - } + return findUpwardRoot(workDir, '.git', exists); } async function pushFirstExisting( diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 9ee2a86a184..2342d095c11 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -5,7 +5,11 @@ * marked `productSpecific` documents this CLI itself — its configuration, * themes, MCP setup — rather than a capability the agent applies to the user's * work, which is what the `builtin_product_skills` switch excludes; those - * names and descriptions otherwise sit in the system prompt every turn. + * names and descriptions otherwise sit in the system prompt every turn. A + * root's `scanMode` defaults to `directory` (full directory scan); + * `root-skill-only` marks the plugin manifest root SKILL.md fallback, where + * the root is a single skill bundle and sibling docs like CHANGELOG.md must + * not be mistaken for flat skills. */ export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; @@ -50,6 +54,7 @@ export interface SkillRoot { readonly path: string; readonly source: SkillSource; readonly plugin?: SkillPluginContext; + readonly scanMode?: 'directory' | 'root-skill-only'; } export interface SkillPluginContext { diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts index d9e082d4d26..16f7f72703b 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts @@ -134,7 +134,7 @@ export class WorkspaceLifecycleService extends Service implements IWorkspaceLife this.instantiation, LifecycleScope.Workspace, workspaceId, - { extra: workspaceContextSeed(ctx) }, + { seeds: workspaceContextSeed(ctx) }, ) as IWorkspaceScopeHandle; this.live.set(workspaceId, handle); this._onDidMaterializeHandler.fire(handle); diff --git a/packages/agent-core-v2/src/session/btw/btw.ts b/packages/agent-core-v2/src/features/btw/btw.ts similarity index 100% rename from packages/agent-core-v2/src/session/btw/btw.ts rename to packages/agent-core-v2/src/features/btw/btw.ts diff --git a/packages/agent-core-v2/src/features/btw/btwFeature.ts b/packages/agent-core-v2/src/features/btw/btwFeature.ts new file mode 100644 index 00000000000..c47d509f424 --- /dev/null +++ b/packages/agent-core-v2/src/features/btw/btwFeature.ts @@ -0,0 +1,26 @@ +/** + * `btw` domain — `BtwFeature`: the side-question ("by the way") capability + * assembled as one App-scope Feature unit. + * + * Contributes the per-Session `ISessionBtwService` through the `features` + * base-class seams; retracting the unit withdraws it across the scope tree. + * Registered into the feature table at import. + */ + +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { ISessionBtwService } from './btw'; +import { SessionBtwService } from './btwService'; + +export class BtwFeature extends Feature { + static override readonly name = 'btw'; + + constructor() { + super(); + this.contributeService(LifecycleScope.Session, ISessionBtwService, SessionBtwService); + } +} + +registerFeature(BtwFeature); diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/features/btw/btwService.ts similarity index 81% rename from packages/agent-core-v2/src/session/btw/btwService.ts rename to packages/agent-core-v2/src/features/btw/btwService.ts index 0b81d2cd7cf..ca1b5556d9b 100644 --- a/packages/agent-core-v2/src/session/btw/btwService.ts +++ b/packages/agent-core-v2/src/features/btw/btwService.ts @@ -5,16 +5,14 @@ * `IAgentLifecycleService.fork`, then disables tool calls via an * `onBeforeExecuteTool` veto listener (blocks every tool call with the * `toolApproval.formatDenyMessage`-formatted TOOL_CALL_DISABLED_MESSAGE) and - * appends the side-channel system reminder. Bound at Session scope — + * appends the side-channel reminder through the child's `systemReminder`. + * Contributed at Session scope by `BtwFeature` (`features/btw/btwFeature`) — * `fork('main')` is a session-level operation, so the service injects the * session's `IAgentLifecycleService` directly rather than resolving it through - * the main agent's accessor. Callers materialize the main agent first; - * forking a missing source throws. + * the main agent's accessor. Callers materialize the main agent first; forking + * a missing source throws. */ -import { LifecycleScope } from '#/app/scopes'; - -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; @@ -35,8 +33,8 @@ export class SessionBtwService implements ISessionBtwService { child.accessor .get(IAgentSystemReminderService) ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, { - kind: 'system_trigger', - name: 'btw', + kind: 'injection', + variant: 'btw', }); const reason = child.accessor.get(IAgentToolApprovalService)?.formatDenyMessage( @@ -50,11 +48,3 @@ export class SessionBtwService implements ISessionBtwService { return child.id; } } - -registerScopedService( - LifecycleScope.Session, - ISessionBtwService, - SessionBtwService, - ScopeActivation.OnScopeCreated, - 'session-btw', -); diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts b/packages/agent-core-v2/src/features/dateChange/dateChange.ts similarity index 59% rename from packages/agent-core-v2/src/agent/dateChange/dateChange.ts rename to packages/agent-core-v2/src/features/dateChange/dateChange.ts index cccb3396eba..d6c19dc53fa 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts +++ b/packages/agent-core-v2/src/features/dateChange/dateChange.ts @@ -1,13 +1,19 @@ /** * `dateChange` domain (L4) — `IAgentDateChangeService` contract. * - * Defines the Agent-scope marker service that announces calendar-date changes - * through a `date_change` context-injection reminder when a session outlives - * the date rendered into its system prompt. + * Defines the Agent-scope marker service and typed disclosure for model-facing + * calendar-date reminders. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +export interface DateInjectionDisclosure { + readonly kind: 'date'; + readonly renderGeneration: number; + readonly localDate: string; + readonly timeZone: string; +} + export interface IAgentDateChangeService { readonly _serviceBrand: undefined; } diff --git a/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts new file mode 100644 index 00000000000..ea336a94723 --- /dev/null +++ b/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts @@ -0,0 +1,19 @@ +import { ScopeActivation } from '#/_base/di/instantiation'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { IAgentDateChangeService } from './dateChange'; +import { AgentDateChangeService } from './dateChangeService'; + +export class DateChangeFeature extends Feature { + static override readonly name = 'dateChange'; + + constructor() { + super(); + this.contributeAgentService(IAgentDateChangeService, AgentDateChangeService, { + activation: ScopeActivation.OnScopeCreated, + }); + } +} + +registerFeature(DateChangeFeature); diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts similarity index 85% rename from packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts rename to packages/agent-core-v2/src/features/dateChange/dateChangeService.ts index dda9554408e..f39b1734d93 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts +++ b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts @@ -16,24 +16,19 @@ */ import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextInjectorService, type ContextInjectionContext, type ContextInjectionResult, } from '#/agent/contextInjector/contextInjector'; -import { - disclosureOfKind, - pickDisclosureBaseline, -} from '#/agent/contextInjector/disclosureBaseline'; +import { pickDisclosureBaseline } from './disclosureBaseline'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; import { IHostClock } from '#/os/interface/hostClock'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { IAgentDateChangeService } from './dateChange'; +import { type DateInjectionDisclosure, IAgentDateChangeService } from './dateChange'; const DATE_CHANGE_INJECTION_VARIANT = 'date_change'; @@ -46,22 +41,25 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan declare readonly _serviceBrand: undefined; constructor( - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentStateService private readonly states: IAgentStateService, @IHostClock private readonly clock: IHostClock, @ISessionContext private readonly sessionContext: ISessionContext, ) { super(); - this.states.register(dateChangeSeedKey); + this._register(this.states.register(dateChangeSeedKey)); this._register( - dynamicInjector.register(DATE_CHANGE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + injector.register( + DATE_CHANGE_INJECTION_VARIANT, + (ctx) => this.reminder(ctx), + ), ); } private reminder({ lastDisclosure, - }: ContextInjectionContext): ContextInjectionResult | undefined { + }: ContextInjectionContext): ContextInjectionResult | undefined { const profileData = this.profile.data(); const environment = profileData.environmentDisclosure; if ( @@ -74,7 +72,7 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan const renderGeneration = profileData.renderGeneration ?? 0; const current = currentDateDisclosure(this.clock); const baseline = pickDisclosureBaseline( - disclosureOfKind(lastDisclosure, 'date'), + lastDisclosure, this.dateFromProfile(), this.states.get(dateChangeSeedKey), ); @@ -135,11 +133,3 @@ function currentDateDisclosure(clock: IHostClock): Omit( - disclosure: ContextInjectionDisclosure | undefined, - kind: K, -): Extract | undefined { - return disclosure?.kind === kind - ? (disclosure as Extract) - : undefined; -} - export function pickDisclosureBaseline( ...candidates: readonly (T | undefined)[] ): T | undefined { diff --git a/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts b/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts new file mode 100644 index 00000000000..65dd6f11125 --- /dev/null +++ b/packages/agent-core-v2/src/features/debugEvents/debugEvents.ts @@ -0,0 +1,48 @@ +/** + * `debugEvents` domain — `IDebugEventsService`: event-subscription + * introspection. + * + * Public contract. `subscriptions()` merges two sides: the precise unit-book + * side (every materialized unit's ledger entries whose label marks an event + * subscription — `on:` from a named `Emitter` or the fiber `on` + * capability, `disposable:EventSubscription` from an unnamed emitter) and the + * emitter-side fallback (listener counts of every materialized `IEventBus` + * instance and the global `IEventService`), which also covers subscriptions + * the caller never registered on a unit book. Unmaterialized on-demand units + * and anonymous fiber units are not enumerable and are simply absent. + * Contributed at App scope through `DebugEventsFeature` — reachable over the + * debug RPC surface through the contributed-service fallback, but absent from + * the static scoped registry (`GET /api/v1/debug/channels`). All payloads are + * JSON-serializable wire data. + */ + +import { createDecorator } from '#/_base/di/instantiation'; +import type { LedgerEntryInfo } from '#/_base/lifecycle/ledger'; + +export interface DebugEventSubscription { + readonly scopePath: string; + readonly unit: string; + readonly uid?: number; + readonly label: string; + readonly kind: LedgerEntryInfo['kind']; +} + +export interface DebugEventBusSnapshot { + readonly scopePath: string; + readonly all: number; + readonly perType: Record; +} + +export interface DebugEventSubscriptions { + readonly subscriptions: DebugEventSubscription[]; + readonly buses: DebugEventBusSnapshot[]; + readonly globalListeners?: number; +} + +export interface IDebugEventsService { + readonly _serviceBrand: undefined; + + subscriptions(): DebugEventSubscriptions; +} + +export const IDebugEventsService = createDecorator('debugEventsService'); diff --git a/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts b/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts new file mode 100644 index 00000000000..8e530d225eb --- /dev/null +++ b/packages/agent-core-v2/src/features/debugEvents/debugEventsFeature.ts @@ -0,0 +1,31 @@ +/** + * `debugEvents` domain — `DebugEventsFeature`: the event-subscription + * introspection capability assembled as one App-scope Feature unit. + * + * Contributes the App-scope `IDebugEventsService` (OnDemand) through the + * `features` base-class seam; retracting the unit withdraws the service + * across the scope tree. The service is intentionally absent from the static + * scoped registry — the debug RPC dispatcher reaches it through the + * contributed-service fallback. Registered into the feature table at import. + */ + +import { ScopeActivation } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { IDebugEventsService } from './debugEvents'; +import { DebugEventsService } from './debugEventsService'; + +export class DebugEventsFeature extends Feature { + static override readonly name = 'debugEvents'; + + constructor() { + super(); + this.contributeService(LifecycleScope.App, IDebugEventsService, DebugEventsService, { + activation: ScopeActivation.OnDemand, + }); + } +} + +registerFeature(DebugEventsFeature); diff --git a/packages/agent-core-v2/src/features/debugEvents/debugEventsService.ts b/packages/agent-core-v2/src/features/debugEvents/debugEventsService.ts new file mode 100644 index 00000000000..08bdc74a3aa --- /dev/null +++ b/packages/agent-core-v2/src/features/debugEvents/debugEventsService.ts @@ -0,0 +1,120 @@ +/** + * `debugEvents` domain — `IDebugEventsService` implementation. + * + * Read-only introspection over the kernel's debug accessors (`children` / + * `servicesSnapshot` / `fiberHost.materializedInstance` / unit-book + * `ledger.entries`), plus listener counters on the `event` domain's bus + * implementations; no kernel state is mutated. Instances resolve up the parent + * chain, so each is attributed to the first container that reaches it and + * deduplicated by identity; unmaterialized on-demand units read as `undefined` + * and are skipped. Contributed at App scope through `DebugEventsFeature`; the + * injected container is the tree root. + */ + +import { IInstantiationService } from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import type { LedgerEntryInfo } from '#/_base/lifecycle/ledger'; +import { IEventService } from '#/app/event/event'; +import { IEventBus } from '#/app/event/eventBus'; +import { walkScopeContainers } from '#/debug/scopeTree'; + +import { + IDebugEventsService, + type DebugEventBusSnapshot, + type DebugEventSubscription, + type DebugEventSubscriptions, +} from './debugEvents'; + +interface UnitBookOwner { + readonly unitBook: { entries(): LedgerEntryInfo[] }; +} + +interface BusCountSource { + listenerCounts(): { all: number; perType: Record }; +} + +interface GlobalCountSource { + readonly listenerCount: number; +} + +export class DebugEventsService implements IDebugEventsService { + declare readonly _serviceBrand: undefined; + + private readonly root: InstantiationService; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.root = instantiation as InstantiationService; + } + + subscriptions(): DebugEventSubscriptions { + const subscriptions: DebugEventSubscription[] = []; + const buses: DebugEventBusSnapshot[] = []; + const seenUnits = new Set(); + const seenBuses = new Set(); + for (const info of walkScopeContainers(this.root)) { + for (const registration of info.container.servicesSnapshot()) { + const id = info.container.findIdentifier(registration.token); + if (id === undefined) { + continue; + } + const instance: unknown = info.container.fiberHost.materializedInstance(id); + if (typeof instance !== 'object' || instance === null || seenUnits.has(instance)) { + continue; + } + seenUnits.add(instance); + if ('unitBook' in instance) { + collectEventEntries((instance as UnitBookOwner).unitBook.entries(), subscriptions, { + scopePath: info.path, + unit: registration.token, + uid: registration.uid, + }); + } + } + const bus: unknown = info.container.fiberHost.materializedInstance(IEventBus); + if (isBusCountSource(bus) && !seenBuses.has(bus)) { + seenBuses.add(bus); + buses.push({ scopePath: info.path, ...bus.listenerCounts() }); + } + } + const globalEvents: unknown = this.root.fiberHost.materializedInstance(IEventService); + const globalListeners = isGlobalCountSource(globalEvents) + ? globalEvents.listenerCount + : undefined; + return { subscriptions, buses, globalListeners }; + } +} + +function collectEventEntries( + entries: readonly LedgerEntryInfo[], + out: DebugEventSubscription[], + base: { scopePath: string; unit: string; uid?: number }, +): void { + for (const entry of entries) { + if (isEventSubscriptionLabel(entry.label)) { + out.push({ ...base, label: entry.label, kind: entry.kind }); + } + if (entry.children !== undefined) { + collectEventEntries(entry.children, out, base); + } + } +} + +function isEventSubscriptionLabel(label: string): boolean { + return label.startsWith('on:') || label === 'disposable:EventSubscription'; +} + +function isBusCountSource(value: unknown): value is BusCountSource { + return ( + typeof value === 'object' && + value !== null && + typeof (value as BusCountSource).listenerCounts === 'function' + ); +} + +function isGlobalCountSource(value: unknown): value is GlobalCountSource { + return ( + typeof value === 'object' && + value !== null && + typeof (value as GlobalCountSource).listenerCount === 'number' + ); +} diff --git a/packages/agent-core-v2/src/features/feature.ts b/packages/agent-core-v2/src/features/feature.ts index f3bd956dcd7..0c5e7b91fb8 100644 --- a/packages/agent-core-v2/src/features/feature.ts +++ b/packages/agent-core-v2/src/features/feature.ts @@ -28,6 +28,7 @@ import { AgentProfileContribution, AGENT_PROFILE_SOURCE_PRIORITY, } from '#/app/agentProfileCatalog/agentProfileContribution'; +import { FeatureServiceContribution } from '#/app/feature/featureServiceContribution'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { ConfigSchema, RegisterSectionOptions } from '#/app/config/config'; import { ConfigSectionContribution } from '#/app/config/configSectionContributions'; @@ -66,6 +67,7 @@ export abstract class Feature extends Service { ctor: ServiceClassRecipe, opts?: FiberProvideOptions, ): FiberHandle { + this.provide(FeatureServiceContribution, { scope, id }); return this.provide(ScopeUnits(scope), { name: `${this.name}:${String(id)}`, apply(fiber: Fiber): void { diff --git a/packages/agent-core-v2/src/features/featureRegistry.ts b/packages/agent-core-v2/src/features/featureRegistry.ts index 4a22b5af612..b08b26412cb 100644 --- a/packages/agent-core-v2/src/features/featureRegistry.ts +++ b/packages/agent-core-v2/src/features/featureRegistry.ts @@ -1,6 +1,9 @@ /** * `features` domain — the module-level feature recipe table ("import = - * register"). + * register") plus the contributed-service table (one entry per + * `Feature.contributeService` call — the record that lets the debug RPC + * dispatcher reach runtime-contributed Services without opening the door to + * arbitrary decorator names). * * Each feature module calls `registerFeature(Recipe)` at its top level; the * assembly drains the table once at App-scope creation. Pure data — no DI, no diff --git a/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts b/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts index 951c99320a4..66d6a32d5fc 100644 --- a/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts +++ b/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts @@ -34,7 +34,7 @@ export const planWasActiveKey = defineState('plan.wasActive', () => fal export class PlanModeInjection extends Service { constructor( - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentPlanService private readonly plan: IAgentPlanService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentStateService private readonly states: IAgentStateService, @@ -43,7 +43,7 @@ export class PlanModeInjection extends Service { this.states.register(planWasActiveKey); this._register( - dynamicInjector.register(PLAN_MODE_INJECTION_VARIANT, async ({ lastInjectedAt: injectedAt }) => { + injector.register(PLAN_MODE_INJECTION_VARIANT, async ({ lastInjectedAt: injectedAt }) => { const data = await this.plan.status(); if (data === null) { if (!this.states.get(planWasActiveKey)) return undefined; diff --git a/packages/agent-core-v2/src/features/plan/planService.ts b/packages/agent-core-v2/src/features/plan/planService.ts index 80aa641d4f7..9babb3154a4 100644 --- a/packages/agent-core-v2/src/features/plan/planService.ts +++ b/packages/agent-core-v2/src/features/plan/planService.ts @@ -74,7 +74,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IHostFileSystem private readonly hostFs: IHostFileSystem, @IBlobStore private readonly blobs: IBlobStore, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IEventBus eventBus: IEventBus, @IWireService private readonly wire: IWireService, @@ -106,7 +106,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { }), ); - this._register(new PlanModeInjection(dynamicInjector, this, this.context, states)); + this._register(new PlanModeInjection(injector, this, this.context, states)); this._register(this.registerPlanGuard(toolExecutor)); } diff --git a/packages/agent-core-v2/src/session/sessionInit/profile/init.md b/packages/agent-core-v2/src/features/sessionInit/profile/init.md similarity index 100% rename from packages/agent-core-v2/src/session/sessionInit/profile/init.md rename to packages/agent-core-v2/src/features/sessionInit/profile/init.md diff --git a/packages/agent-core-v2/src/session/sessionInit/profile/init.ts b/packages/agent-core-v2/src/features/sessionInit/profile/init.ts similarity index 100% rename from packages/agent-core-v2/src/session/sessionInit/profile/init.ts rename to packages/agent-core-v2/src/features/sessionInit/profile/init.ts diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInit.ts b/packages/agent-core-v2/src/features/sessionInit/sessionInit.ts similarity index 100% rename from packages/agent-core-v2/src/session/sessionInit/sessionInit.ts rename to packages/agent-core-v2/src/features/sessionInit/sessionInit.ts diff --git a/packages/agent-core-v2/src/features/sessionInit/sessionInitFeature.ts b/packages/agent-core-v2/src/features/sessionInit/sessionInitFeature.ts new file mode 100644 index 00000000000..30d9f7c7c68 --- /dev/null +++ b/packages/agent-core-v2/src/features/sessionInit/sessionInitFeature.ts @@ -0,0 +1,21 @@ +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { ISessionInitService } from './sessionInit'; +import { SessionInitService } from './sessionInitService'; + +export class SessionInitFeature extends Feature { + static override readonly name = 'sessionInit'; + + constructor() { + super(); + this.contributeService( + LifecycleScope.Session, + ISessionInitService, + SessionInitService, + ); + } +} + +registerFeature(SessionInitFeature); diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts similarity index 95% rename from packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts rename to packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts index 613a1d847c0..f0a4d7d5b46 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts @@ -22,9 +22,6 @@ * `SESSION_INIT_FAILED`) so callers can tell "aborted" from "failed". */ -import { LifecycleScope } from '#/app/scopes'; - -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; @@ -144,11 +141,3 @@ export class SessionInitService implements ISessionInitService { } } } - -registerScopedService( - LifecycleScope.Session, - ISessionInitService, - SessionInitService, - ScopeActivation.OnScopeCreated, - 'session-init', -); diff --git a/packages/agent-core-v2/src/agent/swarm/enter-reminder.md b/packages/agent-core-v2/src/features/swarm/agent/enter-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/swarm/enter-reminder.md rename to packages/agent-core-v2/src/features/swarm/agent/enter-reminder.md diff --git a/packages/agent-core-v2/src/agent/swarm/exit-reminder.md b/packages/agent-core-v2/src/features/swarm/agent/exit-reminder.md similarity index 100% rename from packages/agent-core-v2/src/agent/swarm/exit-reminder.md rename to packages/agent-core-v2/src/features/swarm/agent/exit-reminder.md diff --git a/packages/agent-core-v2/src/features/swarm/agent/injection/swarmInjection.ts b/packages/agent-core-v2/src/features/swarm/agent/injection/swarmInjection.ts new file mode 100644 index 00000000000..694372757ce --- /dev/null +++ b/packages/agent-core-v2/src/features/swarm/agent/injection/swarmInjection.ts @@ -0,0 +1,83 @@ +/** + * `swarm` domain — swarm-mode context injection. + * + * Registers swarm-mode guidance through `contextInjector` and reads + * `contextMemory` for restored legacy state. Used by the Agent-scoped swarm + * service. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, + type ContextInjectionResult, +} from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; + +import SWARM_MODE_ENTER_REMINDER from '../enter-reminder.md?raw'; +import SWARM_MODE_EXIT_REMINDER from '../exit-reminder.md?raw'; +import type { SwarmModeTrigger } from '../swarm'; + +const SWARM_MODE_INJECTION_VARIANT = 'swarm_mode'; +const LEGACY_SWARM_MODE_EXIT_VARIANT = 'swarm_mode_exit'; + +interface SwarmModeInjectionDisclosure { + readonly kind: 'swarm_mode'; + readonly state: 'active' | 'inactive'; +} + +export interface SwarmInjectionOptions { + readonly getTrigger: () => SwarmModeTrigger | null; +} + +export class SwarmInjection extends Disposable { + constructor( + private readonly options: SwarmInjectionOptions, + @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + ) { + super(); + this._register( + injector.register( + SWARM_MODE_INJECTION_VARIANT, + (ctx) => this.reminder(ctx), + ), + ); + } + + private reminder( + ctx: ContextInjectionContext, + ): ContextInjectionResult | undefined { + const trigger = this.options.getTrigger(); + const active = trigger !== null && trigger !== 'tool'; + const rendered = this.renderedState(ctx); + if (active) { + return rendered === 'active' + ? undefined + : { + content: SWARM_MODE_ENTER_REMINDER, + disclosure: { kind: 'swarm_mode', state: 'active' }, + }; + } + return rendered === 'active' + ? { + content: SWARM_MODE_EXIT_REMINDER, + disclosure: { kind: 'swarm_mode', state: 'inactive' }, + } + : undefined; + } + + private renderedState( + ctx: ContextInjectionContext, + ): 'active' | 'inactive' | undefined { + if (ctx.lastDisclosure !== undefined) return ctx.lastDisclosure.state; + const history = this.context.get(); + for (let i = history.length - 1; i >= 0; i--) { + const origin = history[i]!.origin; + if (origin?.kind !== 'injection') continue; + if (origin.variant === LEGACY_SWARM_MODE_EXIT_VARIANT) return 'inactive'; + if (origin.variant === SWARM_MODE_INJECTION_VARIANT) return 'active'; + } + return undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/swarm/swarm.ts b/packages/agent-core-v2/src/features/swarm/agent/swarm.ts similarity index 100% rename from packages/agent-core-v2/src/agent/swarm/swarm.ts rename to packages/agent-core-v2/src/features/swarm/agent/swarm.ts diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/features/swarm/agent/swarmService.ts similarity index 60% rename from packages/agent-core-v2/src/agent/swarm/swarmService.ts rename to packages/agent-core-v2/src/features/swarm/agent/swarmService.ts index fde33429be5..d7e42c9391f 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/agent/swarmService.ts @@ -3,50 +3,50 @@ * * Tracks swarm-mode enter/exit in the `wire` `SwarmModel` (mutated only through * the `swarm_mode.enter` / `swarm_mode.exit` Ops, read through `wire.getModel`), - * mirrors it into `systemReminder` as live-only side effects, derives - * `agent.status.updated` from the Ops' `toEvent`, and auto-exits on turn end via - * `turn`. The enter-reminder removal on exit is a cross-model fold on - * `ContextModel`: dispatching `swarm_mode.exit` pops the - * reminder when it is the last message, both live and on replay — exactly like - * v1's restore-time `popMatchedMessage`. The service only publishes the - * live-only `context.spliced` event for that pop (so injector bookkeeping - * stays in step) and appends the exit reminder when nothing was - * popped. Bound at Agent scope. The service also guards AgentSwarm batch - * exclusivity through an `onBeforeExecuteTool` veto - * listener: an AgentSwarm call must be the only tool call in its batch, + * derives `agent.status.updated` from the Ops' `toEvent`, announces the mode + * through the `swarm_mode` context-injection provider (`SwarmInjection`), + * mirrors replayable trailing-enter removal through `contextMemory`, and + * auto-exits on turn end via `turn`. Bound at Agent scope — contributed into + * every Agent scope by `SwarmFeature` (`features/swarm/swarmFeature`). The + * service also + * guards AgentSwarm batch exclusivity through an `onBeforeExecuteTool` veto + * listener: an AgentSwarm call must be the only tool call in its batch; * anything else is vetoed with a `toolApproval.formatDenyMessage`-formatted * reason. */ import { Service } from '#/_base/di/service'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IInstantiationService } from '#/_base/di/instantiation'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; -import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw'; -import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; + +import { SwarmInjection } from './injection/swarmInjection'; import { IAgentSwarmService, type SwarmModeTrigger } from './swarm'; -import { swarmEnter, swarmExit, SwarmModel } from './swarmOps'; +import { swarmEnter, swarmExit, SwarmModel } from '../swarmOps'; export class AgentSwarmService extends Service implements IAgentSwarmService { declare readonly _serviceBrand: undefined; constructor( @IWireService private readonly wire: IWireService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IInstantiationService instantiation: IInstantiationService, + @IEventBus eventBus: IEventBus, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IEventBus private readonly eventBus: IEventBus, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { super(); this._register( - this.eventBus.subscribe('turn.ended', () => { + instantiation.createInstance(SwarmInjection, { + getTrigger: () => this.wire.getModel(SwarmModel), + }), + ); + this._register( + eventBus.subscribe('turn.ended', () => { if (this.shouldAutoExit) { this.exit(); } @@ -76,36 +76,13 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { enter(trigger: SwarmModeTrigger): void { if (this.wire.getModel(SwarmModel) !== null) return; this.wire.dispatch(swarmEnter({ trigger })); - if (trigger !== 'tool') { - this.reminders.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { - kind: 'injection', - variant: 'swarm_mode', - }); - } } exit(): void { - const trigger = this.wire.getModel(SwarmModel); - if (trigger === null) return; + if (this.wire.getModel(SwarmModel) === null) return; const history = this.context.get(); - const last = history[history.length - 1]; - const willPop = - last?.origin?.kind === 'injection' && last.origin.variant === 'swarm_mode'; this.wire.dispatch(swarmExit({})); - if (trigger === 'tool') return; - if (willPop) { - this.eventBus.publish({ - type: 'context.spliced', - start: history.length - 1, - deleteCount: 1, - messages: [], - }); - return; - } - this.reminders.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { - kind: 'injection', - variant: 'swarm_mode_exit', - }); + this.context.publishTrailingRemoval(history); } get isActive(): boolean { @@ -118,14 +95,6 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { } } -registerScopedService( - LifecycleScope.Agent, - IAgentSwarmService, - AgentSwarmService, - ScopeActivation.OnScopeCreated, - 'swarm', -); - function multipleAgentSwarmDeniedMessage(hasOtherToolCalls: boolean): string { const suffix = hasOtherToolCalls ? ' AgentSwarm also must not be combined with other tools in the same response.' diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts similarity index 100% rename from packages/agent-core-v2/src/session/swarm/agentRunBatch.ts rename to packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts similarity index 100% rename from packages/agent-core-v2/src/session/swarm/sessionSwarm.ts rename to packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts similarity index 94% rename from packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts rename to packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index 811cb21ee77..c4a682afda1 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -15,13 +15,12 @@ * bindings are resolved through the model catalog before lifecycle allocation. * Resumed agents keep the model recorded in their own wire journal — with * per-subagent models there is no "child follows the parent's current model" - * invariant to enforce. Bound at Session scope. + * invariant to enforce. Bound at Session scope — contributed into every + * Session scope by `SwarmFeature` (`features/swarm/swarmFeature`). */ import type { TokenUsage } from '#/kosong/contract/usage'; import { IModelCatalog } from '#/kosong/model/catalog'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2, ErrorCodes } from '#/errors'; import { linkAbortSignal } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; @@ -30,7 +29,6 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus } from '#/app/event/eventBus'; -import { IConfigService } from '#/app/config/config'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -42,10 +40,7 @@ import { } from '#/session/agentLifecycle/subagentMetadata'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import { ISessionSubagentService } from '#/session/subagent/subagent'; -import { - subagentDisplayModel, - wrapSubagentModelError, -} from '#/session/subagent/configSection'; +import { wrapSubagentModelError } from '#/session/subagent/configSection'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -94,7 +89,6 @@ export class SessionSwarmService implements ISessionSwarmService { @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, @ILogService private readonly log: ILogService, @IModelCatalog private readonly modelCatalog: IModelCatalog, - @IConfigService private readonly config: IConfigService, ) {} async getSwarmItem(args: { @@ -192,7 +186,7 @@ export class SessionSwarmService implements ISessionSwarmService { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, - model: subagentDisplayModel(this.config, binding.model), + model: binding.model, }); const promptText = await applyProfilePromptPrefix(profile, options.prompt, { cwd: this.sessionContext.cwd, @@ -227,10 +221,7 @@ export class SessionSwarmService implements ISessionSwarmService { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, - model: - resumedModel === undefined - ? undefined - : subagentDisplayModel(this.config, resumedModel), + model: resumedModel, }); } const request = retryTurn @@ -306,11 +297,3 @@ export class SessionSwarmService implements ISessionSwarmService { } export type _AgentRunUsage = TokenUsage; - -registerScopedService( - LifecycleScope.Session, - ISessionSwarmService, - SessionSwarmService, - ScopeActivation.OnScopeCreated, - 'sessionSwarm', -); diff --git a/packages/agent-core-v2/src/features/swarm/swarmFeature.ts b/packages/agent-core-v2/src/features/swarm/swarmFeature.ts new file mode 100644 index 00000000000..3d28b6dad79 --- /dev/null +++ b/packages/agent-core-v2/src/features/swarm/swarmFeature.ts @@ -0,0 +1,44 @@ +/** + * `swarm` domain — `SwarmFeature`: the agent-swarm capability assembled as + * one App-scope Feature unit. + * + * Contributes the per-Agent `IAgentSwarmService` (swarm mode), the + * per-Session `ISessionSwarmService` (batch scheduler), and the `AgentSwarm` + * agent tool through the `features` base-class seams; retracting the unit + * withdraws all of them across the scope tree. Both services keep + * `OnScopeCreated` activation — `AgentSwarmService` subscribes the + * `turn.ended` auto-exit and the AgentSwarm batch-exclusivity veto in its + * constructor. The `swarm` wire vocabulary (`features/swarm/swarmOps`) stays + * on its static import=register channel — wire records must remain + * replayable even when the feature unit is retracted. Registered into the + * feature table at import. + */ + +import { ScopeActivation } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { IAgentSwarmService } from './agent/swarm'; +import { AgentSwarmService } from './agent/swarmService'; +import { ISessionSwarmService } from './session/sessionSwarm'; +import { SessionSwarmService } from './session/sessionSwarmService'; +import { IAgentSwarmTool } from './tools/agent-swarm/agent-swarm'; +import { AgentSwarmTool } from './tools/agent-swarm/agentSwarmTool'; + +export class SwarmFeature extends Feature { + static override readonly name = 'swarm'; + + constructor() { + super(); + this.contributeAgentService(IAgentSwarmService, AgentSwarmService, { + activation: ScopeActivation.OnScopeCreated, + }); + this.contributeService(LifecycleScope.Session, ISessionSwarmService, SessionSwarmService, { + activation: ScopeActivation.OnScopeCreated, + }); + this.contributeTool(IAgentSwarmTool, AgentSwarmTool, { name: 'AgentSwarm', domain: 'swarm' }); + } +} + +registerFeature(SwarmFeature); diff --git a/packages/agent-core-v2/src/agent/swarm/swarmOps.ts b/packages/agent-core-v2/src/features/swarm/swarmOps.ts similarity index 95% rename from packages/agent-core-v2/src/agent/swarm/swarmOps.ts rename to packages/agent-core-v2/src/features/swarm/swarmOps.ts index d222a575173..06ef2cec4ed 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmOps.ts +++ b/packages/agent-core-v2/src/features/swarm/swarmOps.ts @@ -13,7 +13,7 @@ import { z } from 'zod'; import { defineModel } from '#/wire/model'; -import type { SwarmModeTrigger } from './swarm'; +import type { SwarmModeTrigger } from './agent/swarm'; export const SwarmModel = defineModel('swarm', () => null); diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.md b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.md similarity index 100% rename from packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.md rename to packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.md diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.ts similarity index 74% rename from packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts rename to packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.ts index f1a7349ab2f..8d8d9118519 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agent-swarm.ts +++ b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agent-swarm.ts @@ -1,12 +1,11 @@ /** - * `tools` domain — `IAgentSwarmTool` contract (the `AgentSwarm` tool). + * `swarm` domain — `IAgentSwarmTool` contract (the `AgentSwarm` tool). * * Public contract of the `AgentSwarm` collaboration tool: the input zod * schema the model-facing parameters are derived from, the tool-owned * constants the schema is built around (prompt template placeholder, maximum - * subagent count), and the `IAgentSwarmTool` DI decorator that the - * implementation registers against via `registerAgentToolService`. Bound at - * Agent scope. + * subagent count), and the `IAgentSwarmTool` DI decorator used to resolve the + * implementation through the container. Bound at Agent scope. */ import { z } from 'zod'; @@ -54,10 +53,10 @@ export const AgentSwarmToolInputSchema = z 'Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.', ), model: z - .enum(['secondary', 'primary']) + .string() .optional() .describe( - 'Which model to run the item-spawned subagents on: "secondary" = the configured secondary model; "primary" = the main model you are running on (for hard, quality-sensitive tasks). This explicit choice overrides the selected agent type\'s model_preference; without either, secondary is the default when configured. Only effective when a secondary model is configured; otherwise subagents inherit your model. Resumed subagents always keep their own model.', + 'Which model to run the item-spawned subagents on: one of the aliases listed under "Available models" in this tool description, or "primary" for the main model you are running on (for hard, quality-sensitive tasks). When omitted, the configured default model is used. Resumed subagents always keep their own model.', ), }) .strict(); diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts similarity index 90% rename from packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts rename to packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts index b7d1a0b586a..5c70dd474be 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts @@ -1,5 +1,5 @@ /** - * `tools` domain — `AgentSwarmTool` implementation (the `AgentSwarm` + * `swarm` domain — `AgentSwarmTool` implementation (the `AgentSwarm` * tool). * * Launches a batch of child agents (an ordinary Agent scope each) through the @@ -7,20 +7,19 @@ * per-subagent XML result. Reads persisted swarm item labels through the * Session-scoped coordinator so later `resume_agent_ids` calls relabel * resumed subagents like v1. When the caller has a model bound, the tool - * resolves the explicit or target-profile model preference up front via + * resolves the explicit tool `model` choice up front via * `resolveSubagentBinding` (against `IConfigService`, `IFlagService`, * `ISessionAgentProfileCatalog`, and the caller's `IAgentProfileService`) and * threads it through the swarm tasks; otherwise binding is left to the * service, which keeps its own "no model bound" check and inherit-caller - * fallback. The advertised `model` parameter lists the secondary/primary - * pair via `buildSubagentModelDescriptions`, suffixing each line with the - * entry's capability flags resolved through `IModelCatalog`. Swarm mode is + * fallback. The advertised `model` parameter lists the configured + * `[secondary_model.models]` pool via `buildSubagentModelDescriptions`; the + * pool is gated behind the `secondary-model` experiment, so while it is off + * (or under `[secondary_model].force`) the parameter is not advertised at + * all. Swarm mode is * entered through `IAgentSwarmService`; the caller's agent id comes from - * `IAgentScopeContext`. Pure tool — owns no scoped state. - * - * Registered via the module-level `registerAgentToolService(IAgentSwarmTool, - * AgentSwarmTool)` at the bottom of this file — the same "import = register" - * pattern used by every agent tool. Bound at Agent scope. + * `IAgentScopeContext`. Pure tool — owns no scoped state. Bound at Agent + * scope — contributed by `SwarmFeature` (`features/swarm/swarmFeature`). */ import { @@ -30,12 +29,10 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { Error2, ErrorCodes } from '#/errors'; -import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { IModelCatalog } from '#/kosong/model/catalog'; -import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; +import { ISessionSwarmService, type SessionSwarmTask } from '#/features/swarm/session/sessionSwarm'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; import { @@ -43,14 +40,14 @@ import { subagentTypeNotAllowedMessage, } from '#/app/agentProfileCatalog/profile-shared'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { buildSubagentModelDescriptions, + exposesSubagentModelChoice, resolveSubagentBinding, resolveSubagentTimeoutMs, stripSubagentModelParameter, } from '#/session/subagent/configSection'; -import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { AgentSwarmToolInputSchema, IAgentSwarmTool, @@ -96,7 +93,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { readonly name = 'AgentSwarm' as const; get parameters(): Record { - return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) + return exposesSubagentModelChoice(this.config, this.flags) ? AGENT_SWARM_PARAMETERS : AGENT_SWARM_PARAMETERS_NO_MODEL; } @@ -111,7 +108,6 @@ export class AgentSwarmTool implements IAgentSwarmTool { @IFlagService private readonly flags: IFlagService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IAgentProfileService private readonly profile: IAgentProfileService, - @IModelCatalog private readonly modelCatalog: IModelCatalog, ) { this.callerAgentId = scopeContext.agentId; } @@ -121,7 +117,6 @@ export class AgentSwarmTool implements IAgentSwarmTool { this.config, this.flags, this.profile.data().modelAlias, - this.modelCatalog, ); return modelLines === undefined ? AGENT_SWARM_DESCRIPTION @@ -190,7 +185,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { this.config, this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, - args.model ?? targetProfile.modelPreference, + args.model, ); binding = { model: resolved.model, thinking: resolved.thinking }; } @@ -236,8 +231,6 @@ export class AgentSwarmTool implements IAgentSwarmTool { } } -registerAgentToolService(IAgentSwarmTool, AgentSwarmTool, { name: 'AgentSwarm', domain: 'swarm' }); - async function createAgentSwarmSpecs( args: AgentSwarmToolInput, getResumeItem: (agentId: string) => Promise, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 6845f96ef5e..3c4c4c970bb 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -124,10 +124,16 @@ export * from '#/app/sessionIndex/sessionIndexService'; export * from '#/app/sessionIndex/sessionIndexMirrorService'; export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; +export * from '#/session/sessionMetadata/promptMetadata'; export * from '#/session/sessionActivity/sessionActivity'; export * from '#/session/sessionActivity/sessionActivityService'; export * from '#/session/sessionActivity/sessionOutcomeMirror'; export * from '#/session/sessionActivity/sessionOutcomeMirrorService'; +export * from '#/session/sessionTitle/agentTitlePromptSource'; +import '#/session/sessionTitle/agentTitlePromptSourceService'; +export * from '#/session/sessionTitle/sessionTitle'; +export * from '#/session/sessionTitle/sessionTitleService'; +import '#/session/sessionTitle/flag'; export * from '#/session/sessionToolPolicy/sessionToolPolicy'; export * from '#/session/sessionToolPolicy/sessionToolPolicyService'; export * from '#/app/config/config'; @@ -153,7 +159,6 @@ export * from '#/kosong/protocol/protocol'; export * from '#/kosong/protocol/protocolBase'; export * from '#/kosong/protocol/protocolTrait'; import '#/app/kosongConfig/envOverlay'; -import '#/app/kosongConfig/secondaryModelOverlay'; export * from '#/kosong/model/completionBudget'; export * from '#/kosong/model/hostRequestHeaders'; export * from '#/kosong/model/model'; @@ -169,12 +174,6 @@ export { ModelCatalogConfigSchema, type ModelCatalogConfig, } from '#/app/kosongConfig/configSection'; -export type { SecondaryModelConfig } from '#/app/kosongConfig/configSection'; -export { - SECONDARY_DERIVED_MODEL_ID, - secondaryModelOverlay, - secondaryModelPatch, -} from '#/app/kosongConfig/secondaryModelOverlay'; export * from '#/app/kosongConfig/kosongConfig'; export * from '#/app/kosongConfig/kosongConfigService'; export * from '#/kosong/model/modelOAuth'; @@ -225,6 +224,7 @@ export * from '#/app/capability/capabilityService'; export * from '#/app/capability/errors'; export * from '#/app/capability/types'; export * from '#/app/feature/featureManager'; +export * from '#/app/feature/featureServiceContribution'; import '#/app/feature/featureManagerService'; export * from '#/features/feature'; export * from '#/features/featureAssembly'; @@ -293,6 +293,9 @@ export * from '#/app/flag/flagService'; export * from '#/agent/activityView/activityView'; import '#/agent/activityView/activityViewService'; +export * from '#/features/btw/btw'; +export * from '#/features/btw/btwService'; +import '#/features/btw/btwFeature'; import '#/features/plan/profile/plan'; export * from '#/features/plan/tools/enter-plan-mode/enter-plan-mode'; import '#/features/plan/tools/enter-plan-mode/enterPlanModeTool'; @@ -303,6 +306,16 @@ export * from '#/features/plan/plan'; export * from '#/features/plan/planOps'; export * from '#/features/plan/planService'; import '#/features/plan/planFeature'; +export * from '#/features/debugEvents/debugEvents'; +export * from '#/features/debugEvents/debugEventsService'; +import '#/features/debugEvents/debugEventsFeature'; +export * from '#/features/swarm/agent/swarm'; +export * from '#/features/swarm/agent/swarmService'; +export * from '#/features/swarm/session/sessionSwarm'; +export * from '#/features/swarm/session/sessionSwarmService'; +export * from '#/features/swarm/tools/agent-swarm/agent-swarm'; +import '#/features/swarm/tools/agent-swarm/agentSwarmTool'; +import '#/features/swarm/swarmFeature'; export * from '#/agent/tools/goal/create-goal/create-goal'; import '#/agent/tools/goal/create-goal/createGoalTool'; export * from '#/agent/tools/goal/get-goal/get-goal'; @@ -316,10 +329,6 @@ import '#/agent/goal/goalDeadlineSchedulerService'; export * from '#/agent/goal/goal'; export * from '#/agent/goal/goalService'; export * from '#/agent/goal/types'; -export * from '#/agent/tools/agent-swarm/agent-swarm'; -import '#/agent/tools/agent-swarm/agentSwarmTool'; -export * from '#/agent/swarm/swarm'; -export * from '#/agent/swarm/swarmService'; export * from '#/agent/supermoon/supermoon'; export * from '#/agent/supermoon/supermoonService'; export * from '#/agent/usage/usage'; @@ -336,6 +345,8 @@ export * from '#/agent/toolSelect/toolSelect'; export * from '#/agent/toolSelect/toolSelectService'; export * from '#/agent/toolSelect/toolSelectAnnouncements'; export * from '#/agent/toolSelect/toolSelectAnnouncementsService'; +export * from '#/agent/toolSelect/toolSelectSchemas'; +export * from '#/agent/toolSelect/toolSelectSchemasService'; import '#/agent/toolPolicy/configSection'; export * from '#/agent/toolPolicy/configSection'; export * from '#/agent/toolPolicy/evaluate'; @@ -396,8 +407,8 @@ export * from '#/workspace/workspaceMcp/workspaceMcpService'; export * from '#/session/subagent/subagent'; export * from '#/session/subagent/subagentService'; import '#/session/subagent/flag'; -export * from '#/session/subagent/secondaryModelWarning'; -export * from '#/session/subagent/secondaryModelWarningService'; +export * from '#/session/subagent/subagentModelsValidation'; +import '#/session/subagent/subagentModelsValidationService'; export * from '#/agent/tools/agent/subagent-task'; export { AGENT_RUN_PROMPT_ORIGIN } from '#/session/subagent/runAgentTurn'; export * from '#/session/subagent/mirrorAgentRun'; @@ -411,7 +422,6 @@ export * from '#/workspace/workspaceContext/workspaceContext'; export * from '#/workspace/sessionLifecycle/sessionLifecycle'; export * from '#/workspace/sessionLifecycle/sessionLifecycleService'; export * from '#/workspace/sessionLifecycle/internal/addressing'; -export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; import '#/app/sessionExport/errors'; @@ -559,8 +569,9 @@ export * from '#/agent/contextMemory/contextTranscript'; export * from '#/agent/contextMemory/types'; export * from '#/agent/systemReminder/systemReminder'; export * from '#/agent/systemReminder/systemReminderService'; -export * from '#/agent/dateChange/dateChange'; -export * from '#/agent/dateChange/dateChangeService'; +export * from '#/features/dateChange/dateChange'; +export * from '#/features/dateChange/dateChangeService'; +import '#/features/dateChange/dateChangeFeature'; export * from '#/agent/contextProjector/contextProjector'; export * from '#/agent/contextProjector/contextProjectorService'; export * from '#/agent/tokenCounting/tokenCounting'; @@ -569,6 +580,7 @@ export * from '#/agent/tokenCounting/tokenCountingService'; export * from '#/agent/contextInjector/contextInjector'; export * from '#/agent/contextInjector/contextInjectorService'; export * from '#/agent/plugin/agentPlugin'; +export * from '#/agent/plugin/agentPluginOps'; export * from '#/agent/plugin/agentPluginService'; import '#/agent/externalHooks/configSection'; export * from '#/agent/externalHooks/externalHooks'; @@ -615,29 +627,30 @@ import '#/agent/permissionRules/configSection'; export * from '#/agent/permissionRules/permissionRules'; export * from '#/agent/permissionRules/matchesRule'; export * from '#/agent/permissionRules/permissionRulesService'; +export * from '#/agent/pluginCommand/pluginCommand'; +export * from '#/agent/pluginCommand/pluginCommandService'; export * from '#/agent/profile/profile'; export * from '#/agent/profile/profileService'; export * from '#/agent/profile/context'; export * from '#/agent/prompt/prompt'; export * from '#/agent/prompt/promptService'; +export * from '#/agent/prompt/promptMetadataText'; export * from '#/agent/replayBuilder/types'; +// `replayBuilder/types` inlines its own `SessionSummary`; keep the barrel's +// `SessionSummary` pinned to the session-index one (explicit re-export wins +// over the ambiguous `export *` pair). +export { type SessionSummary } from '#/app/sessionIndex/sessionIndex'; export * from '#/agent/undo/undo'; export * from '#/agent/undo/undoService'; export * from '#/agent/shellCommand/shellCommand'; export * from '#/agent/shellCommand/shellCommandService'; -export * from '#/agent/rpc/rpc'; -export * from '#/agent/rpc/rpcService'; -export * from '#/agent/rpc/prompt-metadata'; export * from '#/agent/scopeContext/scopeContext'; export * from '#/agent/stepRetry/stepRetry'; export * from '#/agent/stepRetry/stepRetryService'; -export * from '#/session/btw/btw'; -export * from '#/session/btw/btwService'; -export * from '#/session/sessionInit/sessionInit'; -export * from '#/session/sessionInit/sessionInitService'; -export * from '#/session/sessionInit/profile/init'; -export * from '#/session/swarm/sessionSwarm'; -export * from '#/session/swarm/sessionSwarmService'; +export * from '#/features/sessionInit/sessionInit'; +export * from '#/features/sessionInit/sessionInitService'; +export * from '#/features/sessionInit/profile/init'; +import '#/features/sessionInit/sessionInitFeature'; export * from '#/session/todo/todoItem'; export * from '#/session/todo/todoListReminder'; export * from '#/session/todo/sessionTodo'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index 5a0cab47642..139e0a3ec7a 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -20,6 +20,11 @@ * the trait-composed `convertError` hook consulted, so a vendor riding this * transport classifies each RAW SDK failure exactly once before the base * rules run. + * + * The SDK client is built with `maxRetries: 0`: the SDK's internal backoff + * sleep never observes the turn's AbortSignal, so rate-limit / server / + * connection retry is owned by the engine's step-retry layer (observable and + * cancellable), never by the SDK. */ import Anthropic, { @@ -1148,6 +1153,7 @@ export class AnthropicChatProvider implements ChatProvider { authToken: null, baseURL: this._baseUrl ?? null, defaultHeaders: this._buildDefaultHeaders(apiKey), + maxRetries: 0, }); } } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts index e838a88004d..f0100faff09 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts @@ -11,6 +11,11 @@ * module's abort plumbing (abortPromise racing, * per-chunk checks, the catch guard that rethrows DOMException aborts before * error conversion) is self-contained by design. + * + * Error conversion recovers the server-directed retry delay from the wire + * body: the SDK's `ApiError` drops response headers, so the + * `google.rpc.RetryInfo` detail inside the stringified error body is the + * only carrier of that wait time. */ import { ApiError as GoogleApiError, GoogleGenAI as GenAIClient } from '@google/genai'; @@ -626,7 +631,12 @@ const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; export function convertGoogleGenAIError(error: unknown): ChatProviderError { if (error instanceof GoogleApiError) { - return normalizeAPIStatusError(error.status, error.message); + return normalizeAPIStatusError( + error.status, + error.message, + undefined, + parseRetryInfoDelayMs(error.message), + ); } if (error instanceof Error) { const msg = error.message; @@ -645,6 +655,32 @@ export function convertGoogleGenAIError(error: unknown): ChatProviderError { return new ChatProviderError(`GoogleGenAI error: ${String(error)}`); } +function parseRetryInfoDelayMs(message: string): number | null { + const jsonStart = message.indexOf('{'); + if (jsonStart < 0) return null; + try { + const body: unknown = JSON.parse(message.slice(jsonStart)); + if (typeof body !== 'object' || body === null) return null; + const details = (body as { error?: { details?: unknown } }).error?.details; + if (!Array.isArray(details)) return null; + for (const detail of details) { + if (typeof detail !== 'object' || detail === null) continue; + const type = (detail as { '@type'?: unknown })['@type']; + if (typeof type !== 'string' || !type.endsWith('google.rpc.RetryInfo')) continue; + const retryDelay = (detail as { retryDelay?: unknown }).retryDelay; + if (typeof retryDelay !== 'string') continue; + const match = /^(\d+(?:\.\d+)?)s$/.exec(retryDelay.trim()); + if (match?.[1] === undefined) continue; + const seconds = Number.parseFloat(match[1]); + if (!Number.isFinite(seconds) || seconds < 0) continue; + return Math.round(seconds * 1000); + } + return null; + } catch { + return null; + } +} + export class GoogleGenAIChatProvider implements ChatProvider { readonly name: string = 'google_genai'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index 5d4bd99e333..99dd83f427d 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -22,6 +22,14 @@ * tool-result `extract_text` fallback and tool-declaration-only skip are * handed over to the trait wholesale: every history message is * base-converted, post-processed by the hook, and dropped on `null`. + * - A reasoning-only assistant is projected with explicit empty `content`. + * The reasoning field remains intact while strict Chat Completions + * gateways still see the required `content` or `tool_calls` shape. + * + * The SDK client is built with `maxRetries: 0`: the SDK's internal backoff + * sleep never observes the turn's AbortSignal, so rate-limit / server / + * connection retry is owned by the engine's step-retry layer (observable and + * cancellable), never by the SDK. */ import OpenAI from 'openai'; @@ -268,6 +276,15 @@ function convertMessage( result.tool_call_id = message.toolCallId; } + if ( + message.role === 'assistant' && + hasReasoningPart && + result.content === undefined && + result.tool_calls === undefined + ) { + result.content = ''; + } + if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) { result[reasoningKey] = reasoningContent; } @@ -751,6 +768,7 @@ export class OpenAILegacyChatProvider implements ChatProvider { const clientOpts: Record = { apiKey, baseURL: this._baseUrl, + maxRetries: 0, }; const defaultHeaders = mergeRequestHeaders(this._defaultHeaders, auth?.headers); if (defaultHeaders !== undefined) { diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index 89e3219de83..5995f01c5ba 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -11,6 +11,11 @@ * classification (already-converted errors crossing an outer catch pass * through without re-consulting). The developer-role model detection lives * here. + * + * The SDK client is built with `maxRetries: 0`: the SDK's internal backoff + * sleep never observes the turn's AbortSignal, so rate-limit / server / + * connection retry is owned by the engine's step-retry layer (observable and + * cancellable), never by the SDK. */ import OpenAI from 'openai'; @@ -1203,6 +1208,7 @@ export class OpenAIResponsesChatProvider implements ChatProvider { const clientOpts: Record = { apiKey, baseURL: this._baseUrl, + maxRetries: 0, }; const defaultHeaders = mergeRequestHeaders(this._defaultHeaders, auth?.headers); if (defaultHeaders !== undefined) { diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts index b44d74c8193..d90a61e0cc5 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts @@ -5,14 +5,22 @@ * login-shell PATH enrichment (`applyLoginShellPathFromNode`) at construction * time; the sync fields become populated once `ready` resolves. Reads before * `ready` throws with a clear message so misuse fails loudly instead of - * returning stale zeros. Bound at App scope. + * returning stale zeros. A failed probe is translated at this boundary — a + * missing Git Bash on Windows becomes `HostProcessError` + * (`shell.git_bash_not_found`) — and surfaces identically from `ready` and + * from sync field reads, while an internal no-op handler keeps the rejection + * from ever becoming an unhandledRejection during App-scope construction. + * Bound at App scope. */ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { BugIndicatingError } from '#/_base/errors/errors'; -import { probeHostEnvironmentFromNode } from '#/_base/execEnv/environmentProbe'; +import { + probeHostEnvironmentFromNode, + ProbeShellNotFoundError, +} from '#/_base/execEnv/environmentProbe'; import { applyLoginShellPathFromNode } from '#/_base/execEnv/loginShellPath'; import { @@ -22,11 +30,13 @@ import { type PathClass, type ShellName, } from '#/os/interface/hostEnvironment'; +import { HostProcessError, OsProcessErrors } from '#/os/interface/hostProcess'; export class HostEnvironmentService implements IHostEnvironment { declare readonly _serviceBrand: undefined; private _info?: HostEnvironmentInfo; + private _probeError?: Error; readonly ready: Promise; constructor() { @@ -35,10 +45,20 @@ export class HostEnvironmentService implements IHostEnvironment { this._info = info; }), applyLoginShellPathFromNode(), - ]).then(() => {}); + ]) + .then(() => {}) + .catch((error: unknown) => { + const translated = this.toHostProcessError(error); + this._probeError = translated; + throw translated; + }); + this.ready.catch(() => {}); } private require(field: keyof HostEnvironmentInfo): never | HostEnvironmentInfo[typeof field] { + if (this._probeError !== undefined) { + throw this._probeError; + } if (this._info === undefined) { throw new BugIndicatingError( `IHostEnvironment.${field} accessed before ready — await IHostEnvironment.ready first (composition root should do so before creating a Session scope).`, @@ -47,6 +67,17 @@ export class HostEnvironmentService implements IHostEnvironment { return this._info[field]; } + private toHostProcessError(error: unknown): Error { + if (error instanceof ProbeShellNotFoundError) { + return new HostProcessError( + OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, + error.message, + { details: { checkedPaths: error.checked }, cause: error }, + ); + } + return error instanceof Error ? error : new Error(String(error)); + } + get osKind(): OsKind { return this.require('osKind') as OsKind; } diff --git a/packages/agent-core-v2/src/os/interface/hostProcess.ts b/packages/agent-core-v2/src/os/interface/hostProcess.ts index 20242ab82fd..d81aaee464e 100644 --- a/packages/agent-core-v2/src/os/interface/hostProcess.ts +++ b/packages/agent-core-v2/src/os/interface/hostProcess.ts @@ -82,6 +82,7 @@ registerErrorDomain(OsProcessErrors); export const HostProcessErrorCode = { SpawnFailed: OsProcessErrors.codes.OS_PROCESS_SPAWN_FAILED, KillFailed: OsProcessErrors.codes.OS_PROCESS_KILL_FAILED, + ShellGitBashNotFound: OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, } as const; export type HostProcessErrorCode = (typeof HostProcessErrorCode)[keyof typeof HostProcessErrorCode]; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 6c7a0d6efe9..4ccbbdd6f55 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -151,7 +151,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle LifecycleScope.Agent, agentId, { - extra: [ + seeds: [ [IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })], [ITelemetryService, this.telemetry.withContext({ agent_id: agentId })], ], 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 04b898f4989..5da3a73a529 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -49,8 +49,6 @@ const AGENT_TOOLS = [ ] as const; const CODER_TOOLS = [ - 'Agent', - 'AgentSwarm', 'Bash', 'CronCreate', 'CronDelete', diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts index cd82ec7a672..c27fb375b4d 100644 --- a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -28,24 +28,22 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IntervalTimer } from '#/_base/utils/timer'; import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; -import type { Hooks } from '#/hooks'; import { IModelService } from '#/kosong/model/model'; import { ISessionAgentProfileCatalog, } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { - ISessionLifecycleHooks, - type SessionCloseReason, - type SessionCreateSource, - type SessionLifecycleHookSlots, -} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { type AgentTaskStartHookContext, type AgentTaskStopHookContext, ISessionSubagentService, } from '#/session/subagent/subagent'; +import { + ISessionLifecycleService, + type SessionCloseReason, + type SessionCreateSource, +} from '#/workspace/sessionLifecycle/sessionLifecycle'; import { ISessionExternalHooksService } from './externalHooks'; @@ -64,7 +62,7 @@ export class SessionExternalHooksService constructor( @ISessionContext private readonly context: ISessionContext, - @ISessionLifecycleHooks lifecycleHooks: Hooks, + @ISessionLifecycleService lifecycle: ISessionLifecycleService, @ISessionSubagentService subagents: ISessionSubagentService, @ISessionMetadata private readonly metadata: ISessionMetadata, @ISessionAgentProfileCatalog private readonly profiles: ISessionAgentProfileCatalog, @@ -90,17 +88,17 @@ export class SessionExternalHooksService }), ); this._register( - lifecycleHooks.onDidCreateSession.register('externalHooks', async (event, next) => { + lifecycle.onDidCreateSession((event) => { + if (event.sessionId !== this.context.sessionId) return; if (event.source !== 'fork') { - await this.triggerSessionStart(event.source); + event.waitUntil(this.triggerSessionStart(event.source)); } - await next(); }), ); this._register( - lifecycleHooks.onWillCloseSession.register('externalHooks', async (event, next) => { - await this.triggerSessionEnd(event.reason); - await next(); + lifecycle.onWillCloseSession((event) => { + if (event.sessionId !== this.context.sessionId) return; + event.waitUntil(this.triggerSessionEnd(event.reason)); }), ); this._register( diff --git a/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts b/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts new file mode 100644 index 00000000000..018adf7acfc --- /dev/null +++ b/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts @@ -0,0 +1,28 @@ +/** + * `mcp` domain — seeded ephemeral per-session MCP server configs. + * + * Defines `ISessionEphemeralMcpServers`, the pure-data injection contract + * carrying the session's ephemeral (caller-injected, never persisted) MCP + * server configs, copied verbatim from the session's creation options + * (`CreateSessionOptions.mcpServers` / `ResumeSessionOptions.mcpServers`). + * Always seeded into the Session scope by the session lifecycle (an empty + * record for ordinary sessions), so consumers can resolve it + * unconditionally. The contract carries no IO of its own — connecting the + * servers and projecting the resulting session handle is the + * Workspace-side MCP domain's concern, activated through the session + * lifecycle's `onWillCreateSession` event. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +export const ISessionEphemeralMcpServers: ServiceIdentifier< + Readonly> +> = createDecorator>>('sessionEphemeralMcpServers'); + +export function sessionEphemeralMcpServersSeed( + servers: Readonly>, +): ScopeSeed { + return [[ISessionEphemeralMcpServers as ServiceIdentifier, servers]]; +} diff --git a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts deleted file mode 100644 index dabb8a0cf49..00000000000 --- a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * `sessionLifecycleHooks` domain — per-session lifecycle hook slots. - * - * Defines the `ISessionLifecycleHooks` seed: one ordered hook-slots instance - * per session, with slots around the session's create (`onDidCreateSession`) - * and close (`onWillCloseSession`). Also owns the shared - * `SessionCreateSource` / `SessionCloseReason` vocabulary. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ScopeSeed } from '#/_base/di/scope'; -import type { Hooks } from '#/hooks'; - -export type SessionCreateSource = 'startup' | 'resume' | 'fork'; - -export type SessionCloseReason = 'exit' | 'archive'; - -export interface SessionStartHookEvent { - readonly source: SessionCreateSource; -} - -export interface SessionEndHookEvent { - readonly reason: SessionCloseReason; -} - -export type SessionLifecycleHookSlots = { - readonly onDidCreateSession: SessionStartHookEvent; - readonly onWillCloseSession: SessionEndHookEvent; -}; - -export const ISessionLifecycleHooks: ServiceIdentifier> = - createDecorator>('sessionLifecycleHooks'); - -export function sessionLifecycleHooksSeed(hooks: Hooks): ScopeSeed { - return [[ISessionLifecycleHooks as ServiceIdentifier, hooks]]; -} diff --git a/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts new file mode 100644 index 00000000000..a2d12da6d15 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts @@ -0,0 +1,56 @@ +/** + * `sessionMetadata` domain — prompt-derived title / lastPrompt updates. + * + * Applies the metadata text derived from a prompt-like entry (prompt, steer, + * skill or plugin-command activation) to the session's durable metadata: + * `lastPrompt` always follows the latest text, while `title` is only derived + * for an untitled session without a custom title. Persists through + * `sessionMetadata` and publishes the live `session.meta.updated` update + * through `event`. Session-scoped by target, called from Agent-scope domains + * (main agent only). + */ + +import type { IEventService } from '#/app/event/event'; + +import { titleFromPromptMetadataText } from '#/agent/prompt/promptMetadataText'; + +import type { ISessionMetadata, SessionTitleKind } from './sessionMetadata'; + +export function isUntitled(title: string | undefined): boolean { + return title === undefined || title.trim().length === 0 || title === 'New Session'; +} + +export interface PromptMetadataUpdateTarget { + readonly metadata: ISessionMetadata; + readonly eventService: IEventService; + readonly sessionId: string; +} + +export async function applyPromptMetadataUpdate( + target: PromptMetadataUpdateTarget, + text: string | undefined, +): Promise { + if (text === undefined) return; + const current = await target.metadata.read(); + const patch: { lastPrompt: string; title?: string; titleKind?: SessionTitleKind } = { + lastPrompt: text, + }; + if (current.titleKind !== 'custom' && isUntitled(current.title)) { + patch.title = titleFromPromptMetadataText(text); + patch.titleKind = 'replaceable'; + } + await target.metadata.update(patch); + target.eventService.publish({ + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: target.sessionId, + title: patch.title, + patch: { + title: patch.title, + isCustomTitle: patch.titleKind === undefined ? undefined : false, + lastPrompt: text, + }, + }, + }); +} diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 11c75c7ff72..fbf7c13e938 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -3,7 +3,8 @@ * * Defines the `SessionMeta` model and the `ISessionMetadata` used by upper * layers to read and update the session's durable metadata (title, timestamps, - * archived flag, fork provenance, the latest main turn's terminal outcome). + * archived flag and the archive moment `archivedAt` — set on archive, cleared + * on restore — fork provenance, the latest main turn's terminal outcome). * Owns the in-memory copy, persists it as a * single atomic document through `storage`, and notifies changes via * `onDidChangeMetadata`. Session-scoped — one instance per session. The initial @@ -24,15 +25,18 @@ export interface AgentMeta { export const SESSION_META_VERSION = 2; +export type SessionTitleKind = 'replaceable' | 'generated' | 'custom'; + export interface SessionMeta { readonly id: string; readonly version?: number; readonly title?: string; - readonly isCustomTitle?: boolean; + readonly titleKind?: SessionTitleKind; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; + readonly archivedAt?: number; readonly cwd?: string; readonly forkedFrom?: string; readonly agents?: Readonly>; @@ -54,6 +58,17 @@ export interface ISessionMetadata { read(): Promise; update(patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }): Promise; setTitle(title: string): Promise; + /** + * Applies a generated title unless the user customized theirs; the title + * kind is re-checked inside the serialized update, right before the write, + * so a custom title set while a generation was in flight still wins. + * `force` skips the kind check entirely (explicit user-requested + * regeneration — last writer wins). + */ + setGeneratedTitleIfUncustomized( + title: string, + opts?: { force?: boolean }, + ): Promise; setArchived(archived: boolean): Promise; registerAgent(agentId: string, meta: AgentMeta): Promise; } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 70505926ec8..6de1a390fef 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -10,10 +10,24 @@ * document always carries the `agents` / `custom` maps — seeded at creation, * backfilled and persisted on load for documents written before the seeding * existed (without touching `updatedAt`, so a format heal never reorders - * session listings). Re-registering an agent whose metadata is unchanged is - * a no-op (no write, no mirror, no event), so resuming a session — which - * re-registers its agents as they materialize — never bumps `updatedAt` and - * never reorders session listings. Bound at Session scope. + * session listings). `updatedAt` tracks content activity only: management + * writes (rename via `setTitle`, archive/restore via `setArchived`, the + * generated-title write-back) keep the persisted value through + * `touchUpdatedAt: false`, an explicit `patch.updatedAt` always wins (fork + * restores the source's recency), and agent registration is a structural + * write that never touches it — neither when resume materializes a cold + * session's agents, nor when a runtime subagent registers mid-turn (the + * turn's own submit/end moments carry recency). The canonical title state + * is `titleKind`; every persist additionally double-writes the v1-readable + * `isCustomTitle` marker derived from it, and on load an explicit + * `isCustomTitle: true` outranks a stale `titleKind` (a v1 rename spreads + * the original document, so the two can disagree) while a `false` marker + * never downgrades a modern generated/custom state. The generated-title + * write path (`setGeneratedTitleIfUncustomized`) serializes through the same + * update queue as everything else and re-checks the title kind inside the + * queued write, so a custom title set while a generation was in flight is + * never overwritten — unless the caller passes `force` (explicit + * regeneration, last writer wins). Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is recorded into the App-scoped @@ -51,6 +65,7 @@ import { type SessionMeta, type SessionMetadataChangedEvent, type SessionMetaPatch, + type SessionTitleKind, } from './sessionMetadata'; const META_KEY = 'state.json'; @@ -115,31 +130,49 @@ export class SessionMetadata extends Service implements ISessionMetadata { patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }, ): Promise { - return this.enqueueUpdate(() => this.applyUpdate(patch, opts)); + return this.enqueueUpdate(async () => { + await this.applyUpdate(patch, opts); + }); } private async applyUpdate( patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }, - ): Promise { + ): Promise { await this.ready; - if (this.disposed) return; - const updatedAt = opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now(); + if (this.disposed) return false; + const updatedAt = + patch.updatedAt ?? (opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now()); this.data = { ...this.data, ...patch, updatedAt }; - await this.store.set(this.scope, META_KEY, this.data); - if (this.disposed) return; + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); + if (this.disposed) return false; this.mirrorToReadModel(); this._onDidChangeMetadata.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[], }); + return true; } async setTitle(title: string): Promise { - await this.update({ title, isCustomTitle: true }); + await this.update({ title, titleKind: 'custom' }, { touchUpdatedAt: false }); + } + + async setGeneratedTitleIfUncustomized( + title: string, + opts?: { force?: boolean }, + ): Promise { + return this.enqueueUpdate(async () => { + await this.ready; + if (opts?.force !== true && this.data.titleKind === 'custom') return false; + return this.applyUpdate({ title, titleKind: 'generated' }, { touchUpdatedAt: false }); + }); } async setArchived(archived: boolean): Promise { - await this.update({ archived }); + await this.update( + archived ? { archived: true, archivedAt: Date.now() } : { archived: false, archivedAt: undefined }, + { touchUpdatedAt: false }, + ); } async registerAgent(agentId: string, meta: AgentMeta): Promise { @@ -148,13 +181,16 @@ export class SessionMetadata extends Service implements ISessionMetadata { const existing = this.data.agents?.[agentId]; if (existing !== undefined && agentMetaEquals(existing, meta)) return; const agents = { ...this.data.agents, [agentId]: meta }; - await this.applyUpdate({ agents }); + await this.applyUpdate({ agents }, { touchUpdatedAt: false }); }); } - private enqueueUpdate(work: () => Promise): Promise { + private enqueueUpdate(work: () => Promise): Promise { const run = this.updateQueue.then(work, work); - const tracked = run.catch(() => {}); + const tracked: Promise = run.then( + () => undefined, + () => undefined, + ); this.updateQueue = tracked; pendingWrites.add(tracked); void tracked.finally(() => pendingWrites.delete(tracked)); @@ -173,6 +209,7 @@ export class SessionMetadata extends Service implements ISessionMetadata { createdAt: this.data.createdAt, updatedAt: this.data.updatedAt, archived: this.data.archived === true, + archivedAt: this.data.archivedAt, custom: this.data.custom, lastTurnReason: this.data.lastTurnReason, }), @@ -192,13 +229,17 @@ export class SessionMetadata extends Service implements ISessionMetadata { const existing = await this.store.get(this.scope, META_KEY); if (existing !== undefined) { this.data = normalizeSessionMeta(existing, this.ctx.sessionId); - if (this.data.agents === undefined || this.data.custom === undefined) { + if ( + this.data.agents === undefined || + this.data.custom === undefined || + sessionMetaTitleNeedsMigration(existing, this.data) + ) { this.data = { ...this.data, agents: this.data.agents ?? {}, custom: this.data.custom ?? {}, }; - await this.store.set(this.scope, META_KEY, this.data); + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); } return; } @@ -213,7 +254,7 @@ export class SessionMetadata extends Service implements ISessionMetadata { agents: {}, custom: {}, }; - await this.store.set(this.scope, META_KEY, this.data); + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); this.mirrorToReadModel(); this.log.debug('session metadata created', { sessionId: this.ctx.sessionId }); } @@ -239,28 +280,83 @@ function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean { } export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta { - const legacy = raw as unknown as { - createdAt?: unknown; - updatedAt?: unknown; - workDir?: unknown; - }; + const legacy = raw as unknown as LegacySessionMeta; + const normalizedTitle = normalizeSessionTitle(legacy); + const { + createdAt: legacyCreatedAt, + updatedAt: legacyUpdatedAt, + workDir: legacyWorkDir, + titleSource: _legacyTitleSource, + isCustomTitle: _legacyIsCustomTitle, + customTitle: _legacyCustomTitle, + ...clean + } = legacy; const cwd = - raw.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0 - ? legacy.workDir + clean.cwd ?? (typeof legacyWorkDir === 'string' && legacyWorkDir.length > 0 + ? legacyWorkDir : undefined); - if (raw.version === SESSION_META_VERSION) { - return cwd === raw.cwd ? raw : { ...raw, cwd }; - } + const { title, titleKind } = normalizedTitle; return { - ...raw, - id: sessionId, + ...clean, + id: clean.version === SESSION_META_VERSION ? clean.id : sessionId, version: SESSION_META_VERSION, cwd, - createdAt: toEpochMs(legacy.createdAt), - updatedAt: toEpochMs(legacy.updatedAt), + title, + titleKind, + createdAt: toEpochMs(legacyCreatedAt), + updatedAt: toEpochMs(legacyUpdatedAt), }; } +type LegacySessionMeta = Omit & { + readonly createdAt?: unknown; + readonly updatedAt?: unknown; + readonly workDir?: unknown; + readonly titleSource?: unknown; + readonly isCustomTitle?: unknown; + readonly customTitle?: unknown; +}; + +function normalizeSessionTitle( + raw: LegacySessionMeta, +): Pick { + const title = typeof raw.title === 'string' ? raw.title : undefined; + if (title !== undefined && raw.isCustomTitle === true) { + return { title, titleKind: 'custom' }; + } + if (title !== undefined && isSessionTitleKind(raw.titleKind)) { + return { title, titleKind: raw.titleKind }; + } + if (title !== undefined && raw.isCustomTitle === false) { + return { title, titleKind: 'replaceable' }; + } + if (typeof raw.customTitle === 'string') { + return { title: raw.customTitle, titleKind: 'custom' }; + } + return title === undefined ? {} : { title, titleKind: 'replaceable' }; +} + +function isSessionTitleKind(value: unknown): value is SessionTitleKind { + return value === 'replaceable' || value === 'generated' || value === 'custom'; +} + +type PersistedSessionMeta = SessionMeta & { readonly isCustomTitle: boolean }; + +function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta { + return { ...meta, isCustomTitle: meta.titleKind === 'custom' }; +} + +function sessionMetaTitleNeedsMigration(raw: SessionMeta, normalized: SessionMeta): boolean { + const record = raw as unknown as Record; + return ( + raw.title !== normalized.title || + raw.titleKind !== normalized.titleKind || + record['isCustomTitle'] !== (normalized.titleKind === 'custom') || + Object.hasOwn(record, 'titleSource') || + Object.hasOwn(record, 'customTitle') + ); +} + export function toEpochMs(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { diff --git a/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts b/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts index f0b15d7fbca..bd1c7968d55 100644 --- a/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts +++ b/packages/agent-core-v2/src/session/sessionSeed/sessionSeedAdapters.ts @@ -23,14 +23,15 @@ * untouched. * * The units carry no DI token of their own: the session - * assembly point constructs them explicitly (`assembleSessionSeedAdapters`, - * the `assemble` hook of `createScopedChildHandle`) and anchors their + * assembly point constructs them explicitly (`installSessionSeedAdapters`, + * the `configureContainer` hook of `createScopedChildHandle`) and anchors their * disposal into the session container's ledger. Observation (`@ref`) is * data-flow semantics — an upstream rebuild re-fires `onDidChange` instead * of cascading this adapter down. A session created with ephemeral - * `mcpServers` passes its merged overlay handle as `sessionMcpHandle`: the - * MCP adapter is skipped and the overlay handle is provided directly (fixed - * at creation, like the pre-adapter inline seed). + * `mcpServers` (the `ISessionEphemeralMcpServers` seed) gets its + * `ISessionMcpHandle` from the `workspaceMcp` participant of the session + * lifecycle's `onWillCreateSession` event instead: its contribution lands + * after this adapter's provide and replaces the workspace projection. */ import type { ServiceClassRecipe } from '#/_base/di/fiber'; @@ -262,15 +263,8 @@ const SESSION_SEED_ADAPTERS: readonly ServiceClassRecipe[] = [ SessionToolPolicyGateAdapter, ]; -export function assembleSessionSeedAdapters( - container: InstantiationService, - sessionMcpHandle?: ISessionMcpHandle, -): void { +export function installSessionSeedAdapters(container: InstantiationService): void { for (const recipe of SESSION_SEED_ADAPTERS) { - if (recipe === SessionMcpHandleAdapter && sessionMcpHandle !== undefined) { - container.provide(ISessionMcpHandle, sessionMcpHandle); - continue; - } const adapter = container.fiberHost.constructService(recipe, undefined) as Partial; container.anchorKernelEntry(() => { adapter.dispose?.(); diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts new file mode 100644 index 00000000000..98bb4f96f1d --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts @@ -0,0 +1,44 @@ +/** + * `sessionTitle` domain (L6) — title prompt projection contract. + * + * Defines the Agent-scoped `IAgentTitlePromptSource` used to read the first + * active natural-language prompts from the live conversation context, plus + * the turn excerpts behind the `first_turn` / `digest` title sources. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +/** + * The first turn's excerpt: the opening natural-language user prompt and the + * final assistant text of that turn. Either side is `undefined` when the + * live window does not (yet) hold it — `first_turn` generation stays strict + * and reports unavailability instead of degrading. + */ +export interface TitleTurnExcerpt { + readonly user?: string | undefined; + readonly assistant?: string | undefined; +} + +/** + * The whole-conversation digest excerpt: the first and last natural-language + * user prompts (collapsed into one when the conversation has a single + * prompt) and the final assistant text of the latest turn. + */ +export interface TitleDigestExcerpt { + readonly firstUser?: string | undefined; + readonly lastUser?: string | undefined; + readonly assistant?: string | undefined; +} + +export interface IAgentTitlePromptSource { + readonly _serviceBrand: undefined; + + firstUserPrompts(limit: number): Promise; + + firstTurnExcerpt(): Promise; + + digestExcerpt(): Promise; +} + +export const IAgentTitlePromptSource: ServiceIdentifier = + createDecorator('agentTitlePromptSource'); diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts new file mode 100644 index 00000000000..45edbab576f --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -0,0 +1,136 @@ +/** + * `sessionTitle` domain (L6) — `IAgentTitlePromptSource` implementation. + * + * Reads the first active natural-language prompts from the live `contextMemory` + * window, merging the `prompt` queue so submissions waiting behind an active + * turn are visible, and projects the turn excerpts behind the `first_turn` / + * `digest` title sources: assistant segments keep only the final natural + * language text of the turn (tool calls, thinking, and media parts never + * contribute; the shared metadata sanitizer redacts secrets and long + * base64-looking runs). The window may be post-compaction — acceptable for + * title generation: compaction keeps the head user messages, and a title + * derived from the surviving tail is a fine degradation. Bound at Agent + * scope. + */ + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { + promptMetadataTextFromContentParts, + promptMetadataTextFromText, +} from '#/agent/prompt/promptMetadataText'; +import type { ContentPart } from '#/kosong/contract/message'; + +import { + IAgentTitlePromptSource, + type TitleDigestExcerpt, + type TitleTurnExcerpt, +} from './agentTitlePromptSource'; + +export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentPromptService private readonly prompt: IAgentPromptService, + ) {} + + async firstUserPrompts(limit: number): Promise { + if (!Number.isSafeInteger(limit) || limit <= 0) return []; + + const result: string[] = []; + const seenMessageIds = new Set(); + + const add = (message: ContextMessage): void => { + if (result.length >= limit || !isNaturalLanguagePrompt(message)) return; + if (message.id !== undefined) { + if (seenMessageIds.has(message.id)) return; + seenMessageIds.add(message.id); + } + const text = promptMetadataTextFromContentParts(message.content); + if (text !== undefined) result.push(text); + }; + + for (const message of this.combinedMessages()) add(message); + return result; + } + + async firstTurnExcerpt(): Promise { + const all = this.combinedMessages(); + const firstUserIndex = all.findIndex(isNaturalLanguagePrompt); + if (firstUserIndex < 0) return {}; + const user = promptMetadataTextFromContentParts(all[firstUserIndex]!.content); + const span: ContextMessage[] = []; + for (const message of all.slice(firstUserIndex + 1)) { + if (isNaturalLanguagePrompt(message)) break; + span.push(message); + } + return { user, assistant: finalAssistantText(span) }; + } + + async digestExcerpt(): Promise { + const all = this.combinedMessages(); + const firstUserIndex = all.findIndex(isNaturalLanguagePrompt); + if (firstUserIndex < 0) return {}; + let lastUserIndex = -1; + for (let index = all.length - 1; index >= 0; index--) { + if (isNaturalLanguagePrompt(all[index]!)) { + lastUserIndex = index; + break; + } + } + const firstUser = promptMetadataTextFromContentParts(all[firstUserIndex]!.content); + const lastUser = + lastUserIndex > firstUserIndex + ? promptMetadataTextFromContentParts(all[lastUserIndex]!.content) + : undefined; + const assistant = + finalAssistantText(all.slice(lastUserIndex + 1)) ?? + finalAssistantText(all.slice(firstUserIndex + 1)); + return { firstUser, lastUser, assistant }; + } + + private combinedMessages(): ContextMessage[] { + const queue = this.prompt.list(); + const all = [...this.context.get()]; + if (queue.active !== undefined) all.push(queue.active.message); + for (const item of queue.pending) all.push(item.message); + return all; + } +} + +function isNaturalLanguagePrompt(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + return origin === undefined || origin.kind === 'user'; +} + +function finalAssistantText(messages: readonly ContextMessage[]): string | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]!; + if (message.role !== 'assistant') continue; + const text = assistantTextFromContentParts(message.content); + if (text !== undefined) return text; + } + return undefined; +} + +function assistantTextFromContentParts(parts: readonly ContentPart[]): string | undefined { + const texts: string[] = []; + for (const part of parts) { + if (part.type === 'text' && part.text.trim().length > 0) texts.push(part.text); + } + if (texts.length === 0) return undefined; + return promptMetadataTextFromText(texts.join('\n')); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentTitlePromptSource, + AgentTitlePromptSourceService, + ScopeActivation.OnDemand, + 'sessionTitle', +); diff --git a/packages/agent-core-v2/src/session/sessionTitle/flag.ts b/packages/agent-core-v2/src/session/sessionTitle/flag.ts new file mode 100644 index 00000000000..0303878b291 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/flag.ts @@ -0,0 +1,25 @@ +/** + * `sessionTitle` domain — experimental flag for AI session title generation. + * + * Gates every `generateTitle` entry point (the kap-server route, klient, and + * through them the desktop/web auto trigger and rename-field action). Off by + * default; enable via `KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE`, the master + * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. + */ + +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const AUTO_SESSION_TITLE_FLAG_ID = 'auto_session_title'; +export const AUTO_SESSION_TITLE_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE'; + +export const sessionTitleFlag: FlagDefinitionInput = { + id: AUTO_SESSION_TITLE_FLAG_ID, + title: 'AI session titles', + description: + 'Generate concise session titles from the conversation through the managed chat_title tool: clients auto-generate once the first turn completes and offer on-demand regeneration in the rename field.', + env: AUTO_SESSION_TITLE_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(sessionTitleFlag); diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts new file mode 100644 index 00000000000..b5e36709388 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -0,0 +1,35 @@ +/** + * `sessionTitle` domain (L6) — session title generation contract. + * + * Defines the Session-scoped `ISessionTitleService` that generates a + * session title from the main Agent's conversation history. An + * already-generated title is not regenerated; a custom title is never + * overwritten — unless the caller passes `force` (an explicit + * user-requested regeneration). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +/** + * Which conversation excerpt a title generation draws from: + * - `user_prompts` (default): the first natural-language user prompts. + * - `first_turn`: the opening user prompt plus the first turn's final + * assistant text; strict — unavailable until the first turn has produced + * an assistant reply. + * - `digest`: first user prompt + latest user prompt + the latest turn's + * final assistant text, using whatever the (possibly compacted) window + * still holds; meant for explicit regeneration on multi-turn sessions. + */ +export type SessionTitleSource = 'user_prompts' | 'first_turn' | 'digest'; + +export interface ISessionTitleService { + readonly _serviceBrand: undefined; + + generateTitle(opts?: { + force?: boolean; + source?: SessionTitleSource; + }): Promise; +} + +export const ISessionTitleService: ServiceIdentifier = + createDecorator('sessionTitleService'); diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts new file mode 100644 index 00000000000..1d45e9ea5f0 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -0,0 +1,229 @@ +/** + * `sessionTitle` domain (L6) — `ISessionTitleService` implementation. + * + * Generates the session's title from the first active prompts in the main + * Agent's live conversation context through the managed platform `/tools` + * `chat_title` endpoint, persists it through + * `sessionMetadata`, and rebroadcasts `session.meta.updated`. + * Generation is on demand only: `generateTitle()` is the single entry point + * (the kap-server route), gated by the experimental `auto_session_title` flag and + * a managed Kimi Code OAuth login; any + * failure degrades to keeping the current title, and a custom title set by + * the user is never overwritten. An already-generated title is not + * regenerated. Concurrent calls coalesce onto one shared in-flight + * generation. `force` requests an explicit user-driven regeneration: it + * bypasses the in-flight coalescing and both title-kind guards, and the + * applied title is marked `generated` (a previous custom marking is + * dropped). The `source` option picks the conversation excerpt sent to the + * backend (see `SessionTitleSource`): the default first-prompts window, the + * strict `first_turn` user+assistant pair, or the head+tail `digest` for + * multi-turn regeneration. + * Provider config comes + * from `provider`, the bearer token from `auth`, host identity headers from + * `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs + * through `log`. Bound at Session scope. + */ + +import { + KIMI_CODE_PROVIDER_NAME, + OAuthError, + fetchChatTitle, + kimiCodeToolsUrl, + parseKimiCodeCustomHeaders, + resolveKimiCodeRuntimeAuth, +} from '@moonshot-ai/kimi-code-oauth'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IFlagService } from '#/app/flag/flag'; +import { ILogService } from '#/_base/log/log'; +import { IOAuthService } from '#/app/auth/auth'; +import { IEventService } from '#/app/event/event'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { IProviderService } from '#/kosong/provider/provider'; +import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; + +import { IAgentTitlePromptSource } from './agentTitlePromptSource'; +import { AUTO_SESSION_TITLE_FLAG_ID } from './flag'; +import { ISessionTitleService, type SessionTitleSource } from './sessionTitle'; + +const MAX_GENERATED_TITLE_LENGTH = 200; + +const MAX_TITLE_INPUT_LENGTH = 1000; + +const MAX_TITLE_PROMPTS = 3; + +/** Per-segment excerpt budgets inside the composed chat_content. */ +const MAX_TITLE_USER_SEGMENT = 300; + +const MAX_TITLE_FIRST_TURN_ASSISTANT = 600; + +const MAX_TITLE_DIGEST_ASSISTANT = 400; + +export class SessionTitleService implements ISessionTitleService { + declare readonly _serviceBrand: undefined; + + private _shared: Promise | undefined; + + constructor( + @ISessionContext private readonly ctx: ISessionContext, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @IEventService private readonly eventService: IEventService, + @IProviderService private readonly providers: IProviderService, + @IOAuthService private readonly oauth: IOAuthService, + @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, + @IFlagService private readonly flags: IFlagService, + @ILogService private readonly log: ILogService, + ) {} + + async generateTitle(opts?: { + force?: boolean; + source?: SessionTitleSource; + }): Promise { + const force = opts?.force === true; + const source = opts?.source ?? 'user_prompts'; + if (force) return this.generateTitleOnce(true, source); + if (this._shared !== undefined) return this._shared; + const tracked = this.generateTitleOnce(false, source).finally(() => { + if (this._shared === tracked) this._shared = undefined; + }); + this._shared = tracked; + return tracked; + } + + private async generateTitleOnce( + force: boolean, + source: SessionTitleSource, + ): Promise { + if (!this.flags.enabled(AUTO_SESSION_TITLE_FLAG_ID)) return undefined; + const current = await this.metadata.read(); + if (!force) { + if (current.titleKind === 'custom') return undefined; + if (current.titleKind === 'generated') return undefined; + } + const main = this.agentLifecycle.get(MAIN_AGENT_ID); + if (main === undefined) return undefined; + const promptSource = main.accessor.get(IAgentTitlePromptSource); + const input = await composeTitleInput(promptSource, source); + if (input === undefined) return undefined; + return this.generateAndApply(input, force); + } + + private async generateAndApply( + chatContent: string, + force: boolean, + ): Promise { + const current = await this.metadata.read(); + if (!force && current.titleKind === 'custom') return undefined; + const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); + if ( + provider === undefined || + !isOAuthCatalogVendor(provider.type) || + provider.oauth === undefined + ) { + return undefined; + } + const runtimeAuth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: provider.baseUrl, + configuredOAuthRef: provider.oauth, + }); + const tokenProvider = this.oauth.resolveTokenProvider( + KIMI_CODE_PROVIDER_NAME, + runtimeAuth.oauthRef, + ); + if (tokenProvider === undefined) return undefined; + let token: string; + try { + token = await tokenProvider.getAccessToken(); + } catch (error) { + if (!(error instanceof OAuthError)) throw error; + this.log.debug(`chat_title request unavailable: ${error.message}`); + return undefined; + } + const requestTitle = (accessToken: string) => + fetchChatTitle(kimiCodeToolsUrl(runtimeAuth.baseUrl), accessToken, chatContent, { + headers: { + ...parseKimiCodeCustomHeaders(), + ...this.hostHeaders.headers, + ...provider.customHeaders, + }, + }); + let result = await requestTitle(token); + if (result.kind === 'error' && result.status === 401) { + try { + token = await tokenProvider.getAccessToken({ force: true }); + } catch (error) { + if (!(error instanceof OAuthError)) throw error; + this.log.debug(`chat_title request unavailable: ${error.message}`); + return undefined; + } + result = await requestTitle(token); + } + if (result.kind !== 'ok') { + this.log.debug(`chat_title request failed: ${result.message}`); + return undefined; + } + const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); + const applied = await this.metadata.setGeneratedTitleIfUncustomized(title, { force }); + if (!applied) return undefined; + this.eventService.publish({ + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: this.ctx.sessionId, + title, + patch: { title, isCustomTitle: false }, + }, + }); + return title; + } +} + +function titleInputFromPrompts(prompts: readonly string[]): string | undefined { + if (prompts.length === 0) return undefined; + return prompts + .map((prompt) => `user: ${prompt}`) + .join('\n') + .slice(0, MAX_TITLE_INPUT_LENGTH); +} + +async function composeTitleInput( + promptSource: IAgentTitlePromptSource, + source: SessionTitleSource, +): Promise { + if (source === 'first_turn') { + const excerpt = await promptSource.firstTurnExcerpt(); + if (excerpt.user === undefined || excerpt.assistant === undefined) return undefined; + return [ + `user: ${excerpt.user.slice(0, MAX_TITLE_USER_SEGMENT)}`, + `assistant: ${excerpt.assistant.slice(0, MAX_TITLE_FIRST_TURN_ASSISTANT)}`, + ].join('\n'); + } + if (source === 'digest') { + const excerpt = await promptSource.digestExcerpt(); + const lines: string[] = []; + if (excerpt.firstUser !== undefined) { + lines.push(`user: ${excerpt.firstUser.slice(0, MAX_TITLE_USER_SEGMENT)}`); + } + if (excerpt.lastUser !== undefined) { + lines.push(`user: ${excerpt.lastUser.slice(0, MAX_TITLE_USER_SEGMENT)}`); + } + if (excerpt.assistant !== undefined) { + lines.push(`assistant: ${excerpt.assistant.slice(0, MAX_TITLE_DIGEST_ASSISTANT)}`); + } + return lines.length === 0 ? undefined : lines.join('\n'); + } + return titleInputFromPrompts(await promptSource.firstUserPrompts(MAX_TITLE_PROMPTS)); +} + +registerScopedService( + LifecycleScope.Session, + ISessionTitleService, + SessionTitleService, + ScopeActivation.OnScopeCreated, + 'sessionTitle', +); diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 38c743ac3a3..c84b012b341 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -1,57 +1,107 @@ /** - * `subagent` domain — subagent config-section schema, env binding, and + * `subagent` domain — subagent config-section schemas, env binding, and * timeout / model resolution. * - * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together - * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override (precedence: env > - * config.toml > 2h default). While - * the env var is set, `stripEnvBoundFields` restores the env-free raw value - * before persistence, so the override never leaks into `config.toml`. Per-run - * timeouts resolve through `resolveSubagentTimeoutMs`, and the timeout - * message renders with `formatSubagentTimeoutDescription`. + * Owns two on-disk sections: * - * The model half of the spawn binding is the secondary model (the - * `[secondary_model]` section on disk): when its - * experiment is enabled and the model is set, newly spawned subagents bind to - * it by default instead of inheriting the caller's model, and the - * `Agent`/`AgentSwarm` tools let the parent model pick per spawn via their - * `model` parameter. When unset, spawning behavior is unchanged (subagents - * inherit the caller's model). A recipe with patch fields binds the - * synthesized derived entry (`SECONDARY_DERIVED_MODEL_ID`); a pointer-only - * recipe binds the pointed entry directly. `default_effort` is passed as the - * explicit subagent thinking; without it the subagent resolves thinking + * - `[subagent]` — `timeout_ms`, together with the `KIMI_SUBAGENT_TIMEOUT_MS` + * env override (precedence: env > config.toml > 2h default). While the env + * var is set, `stripEnvBoundFields` restores the env-free raw value before + * persistence, so the override never leaks into `config.toml`. Per-run + * timeouts resolve through `resolveSubagentTimeoutMs`, and the timeout + * message renders with `formatSubagentTimeoutDescription`. + * + * - `[secondary_model]` — the subagent model pool: `default_model` names the + * fallback model and the `[secondary_model.models]` table maps alias → + * description. A `default_model` without a `[secondary_model.models]` table + * stands on its own as an implicit single-entry pool (empty description) — + * the minimal "secondary model" configuration. As a compatibility fallback + * for the v1 engine's recipe, a lone legacy `model` key (likewise without a + * pool table) forms the same implicit single-entry pool, ranked below + * `default_model`; the recipe's patch fields (`default_effort`, ...) have + * no pool counterpart and are ignored by pool resolution — but the schema + * still declares them so validation never strips them and config + * reads/writes round-trip losslessly for the v1 engine, and `model` never + * substitutes for + * the pool table's required `default_model`. `force = true` instead + * removes the choice entirely: every spawn binds the resolved default + * (`default_model` ?? `model`), the tools hide the `model` parameter + * exactly like the no-pool case, and combining + * it with a `[secondary_model.models]` table is rejected — the table's only + * purpose is offering the main agent a choice. + * + * When a pool is configured (and not forced), newly spawned subagents + * bind to the pool's default model unless the parent model picks a pool alias + * — or `primary` (`PRIMARY_SUBAGENT_MODEL_CHOICE`), the always-available + * symbolic choice binding the caller's own model and thinking level — per + * spawn via the `Agent` / `AgentSwarm` tool `model` parameter. Pool bindings + * carry no explicit thinking level, so the subagent resolves thinking * naturally (global thinking config → the bound model's default effort) - * rather than inheriting the caller's level. Both tools resolve spawn - * bindings through `resolveSubagentBinding`, advertise the pair via - * `buildSubagentModelDescriptions` (each line suffixed with the entry's - * resolved capability flags, so the parent can route multimodal or - * thinking-heavy subagent tasks instead of guessing from the model id), - * and wrap spawn failures with - * `wrapSubagentModelError`; while the experiment is off they also strip the - * no-op `model` parameter from their advertised schemas via - * `stripSubagentModelParameter`. Spawn reporting reads the display-facing - * alias from `subagentDisplayModel`: the derived entry id means nothing to a - * user, so it resolves back to the recipe's base alias — flag-independent on - * purpose, since interpreting an already-persisted derived binding (resume) - * must keep working after the experiment is switched off. Self-registered - * at module load via `registerConfigSection`. + * rather than inheriting the caller's level. Without a pool, spawning + * behavior is unchanged (subagents inherit the caller's model) and the tools + * strip the no-op `model` parameter from their advertised schemas via + * `stripSubagentModelParameter`, so the concept never enters the prompt and a + * stray `model` argument is rejected instead of silently inheriting; the + * strip returns a shallow copy and never mutates the input, so callers can + * keep both schema variants as shared constants. `force = true` shares this + * hidden-parameter surface (see `exposesSubagentModelChoice`) while binding + * every spawn to the resolved default in `resolveSubagentBinding`. The whole + * pool is gated behind the `secondary-model` experimental flag (`flag.ts`): + * while the experiment is off the section is inert — the tools strip the + * `model` parameter, spawns bind the caller's model, and validation is + * skipped. + * + * Spawn bindings resolve through `resolveSubagentBinding`: a forced + * configuration short-circuits to the resolved default before anything else, and + * any explicit request — `primary` included — throws (defensive; the tools + * strip the parameter); `primary` + * short-circuits to the caller's own model+thinking; with no pool a stray + * non-`primary` request throws (defensive — the tools strip the parameter); + * with a pool the request must be a pool alias, an omitted request falls back + * to `default_model`, and anything else throws `CONFIG_INVALID` listing the + * available choices so the parent model can retry. The tools advertise the + * pool via `buildSubagentModelDescriptions`: the default model leads with a + * `[default]` marker, the remaining aliases follow in config order, and the + * caller's own alias is listed like any other pool entry (marked + * `[main model]`) — pool alias bindings carry no thinking, so the trailing + * `primary` line stays distinct from it: it binds the caller's model WITH the + * caller's current thinking level, and names the alias in parentheses when + * the caller is in the pool. An empty-string description renders a bare + * `- alias` line. Spawn failures are wrapped by `wrapSubagentModelError`: + * when the bound model is not the caller's own and the catalog failed on + * exactly that alias, the parent model gets guidance toward + * `[secondary_model.models]` instead of a bare resolution error. + * Cross-field validation is NOT part of the schema — it is enforced as + * `Error2(CONFIG_INVALID)` by `assertValidSubagentModelConfig` (run before + * session materialization by the session lifecycle, with the Session-scope + * validation service in `subagentModelsValidationService.ts` as backstop), + * which checks the `force` rules (a default — `default_model` or the legacy + * `model` fallback — required, a + * `[secondary_model.models]` table rejected) and delegates the pool checks to + * `assertValidSubagentModelPool`: the default must be present and name a + * pool key, every pool key must resolve through the model catalog, and the + * reserved `primary` alias is rejected outright — as a pool key it would be + * unreachable (explicit requests short-circuit to the caller's model) and + * would render a self-contradictory description. `resolveSubagentBinding` + * repeats the reserved-key and force-rule checks so a pool broken by a + * runtime config edit fails loudly at spawn instead of binding the wrong + * model; any other malformation the startup checks missed surfaces as the + * spawn-time errors above. Writes that rewrite the `[models]` table + * (provider removal/replace at the edge, background catalog refreshes) + * fold the pool through `cascadeSubagentModelPool` into the same atomic + * write — renamed aliases are repointed, dropped aliases filtered, and the + * whole section cleared when its effective default dangles (an emptied pool + * table folds into the implicit single-entry form — a default naming no + * pool key would fail validation) — so the startup + * validation never meets a pool orphaned by a write it did not see. + * Self-registered at module load via `registerConfigSection`. */ import { z } from 'zod'; import { Error2, ErrorCodes, isError2 } from '#/errors'; -import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { isPlainObject } from '#/app/config/toml'; import type { IFlagService } from '#/app/flag/flag'; -import { - SECONDARY_MODEL_ENV, - SECONDARY_MODEL_SECTION, -} from '#/app/kosongConfig/configSection'; -import { - SECONDARY_DERIVED_MODEL_ID, - secondaryModelPatch, -} from '#/app/kosongConfig/secondaryModelOverlay'; -import { type SecondaryModelConfig } from '#/app/kosongConfig/configSection'; import { type EnvBindings, envBindings, @@ -59,12 +109,12 @@ import { type IConfigService, } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; -import type { ModelCapability } from '#/kosong/contract/capability'; import type { IModelCatalog } from '#/kosong/model/catalog'; import { SECONDARY_MODEL_FLAG_ID } from './flag'; export const SUBAGENT_SECTION = 'subagent'; +export const SECONDARY_MODEL_SECTION = 'secondaryModel'; export const SubagentConfigSchema = z.object({ timeoutMs: z.number().int().min(0).optional(), @@ -72,6 +122,25 @@ export const SubagentConfigSchema = z.object({ export type SubagentConfig = z.infer; +export const SecondaryModelConfigSchema = z.object({ + defaultModel: z.string().min(1).optional(), + models: z.record(z.string(), z.string()).optional(), + force: z.boolean().optional(), + model: z.string().min(1).optional(), + maxContextSize: z.number().int().min(1).optional(), + maxInputSize: z.number().int().min(1).optional(), + maxOutputSize: z.number().int().min(1).optional(), + capabilities: z.array(z.string()).optional(), + displayName: z.string().optional(), + reasoningKey: z.string().optional(), + adaptiveThinking: z.boolean().optional(), + supportEfforts: z.array(z.string()).optional(), + defaultEffort: z.string().optional(), + offEffort: z.string().optional(), +}); + +export type SecondaryModelConfig = z.infer; + export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000; export const SUBAGENT_TIMEOUT_ENV = 'KIMI_SUBAGENT_TIMEOUT_MS'; @@ -96,6 +165,8 @@ registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, { stripEnv: stripSubagentEnv, }); +registerConfigSection(SECONDARY_MODEL_SECTION, SecondaryModelConfigSchema); + export function resolveSubagentTimeoutMs(config: IConfigService): number { return ( config.get(SUBAGENT_SECTION)?.timeoutMs ?? @@ -103,91 +174,247 @@ export function resolveSubagentTimeoutMs(config: IConfigService): number { ); } -export type SubagentModelChoice = AgentModelPreference; +export const PRIMARY_SUBAGENT_MODEL_CHOICE = 'primary'; + +export interface SubagentModelPool { + readonly defaultModel?: string; + readonly models: Record; +} + +export function resolveSubagentModelPool(config: IConfigService): SubagentModelPool | undefined { + const section = config.get(SECONDARY_MODEL_SECTION); + if (section?.models !== undefined) { + return { defaultModel: section.defaultModel, models: section.models }; + } + if (section?.defaultModel !== undefined) { + return { defaultModel: section.defaultModel, models: { [section.defaultModel]: '' } }; + } + if (section?.model !== undefined) { + return { defaultModel: section.model, models: { [section.model]: '' } }; + } + return undefined; +} + +export const SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE = + '[secondary_model].default_model is required when [secondary_model].force is set'; + +export const SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE = + '[secondary_model].force cannot be combined with [secondary_model.models]: the pool table only exists to offer the main agent a choice, and force removes that choice'; + +export function isSubagentModelForced(config: IConfigService): boolean { + return config.get(SECONDARY_MODEL_SECTION)?.force === true; +} + +export function exposesSubagentModelChoice(config: IConfigService, flags: IFlagService): boolean { + if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return false; + if (isSubagentModelForced(config)) return false; + return resolveSubagentModelPool(config) !== undefined; +} + +export const SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE = + '[secondary_model].default_model is required when [secondary_model.models] is configured'; + +export const SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE = `[secondary_model.models] key "${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved: it always binds the caller's own model. Rename the pool entry.`; + +export function assertValidSubagentModelPool( + pool: SubagentModelPool, + modelCatalog: IModelCatalog, +): void { + if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, { + details: { + section: SECONDARY_MODEL_SECTION, + field: 'models', + model: PRIMARY_SUBAGENT_MODEL_CHOICE, + }, + }); + } + const aliases = Object.keys(pool.models); + if (pool.defaultModel === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } + if (!Object.hasOwn(pool.models, pool.defaultModel)) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `[secondary_model].default_model "${pool.defaultModel}" is not a [secondary_model.models] key. Available models: ${aliases.join(', ')}.`, + { details: { model: pool.defaultModel, availableModels: aliases } }, + ); + } + for (const alias of aliases) { + try { + modelCatalog.get(alias); + } catch (error) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `[secondary_model.models] entry "${alias}" could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + { cause: error, details: { model: alias } }, + ); + } + } +} -export function resolveSecondaryModel( +export function assertValidSubagentModelConfig( config: IConfigService, flags: IFlagService, -): SecondaryModelConfig | undefined { - if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return undefined; - return config.get(SECONDARY_MODEL_SECTION); + modelCatalog: IModelCatalog, +): void { + if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return; + const section = config.get(SECONDARY_MODEL_SECTION); + if (section?.force === true) { + if (section.models !== undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'force' }, + }); + } + if (section.defaultModel === undefined && section.model === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } + } + const pool = resolveSubagentModelPool(config); + if (pool !== undefined) assertValidSubagentModelPool(pool, modelCatalog); +} + +export function cascadeSubagentModelPool( + section: SecondaryModelConfig | undefined, + survivingModels: Record, + renamedAliases: ReadonlyMap = new Map(), +): SecondaryModelConfig | null | undefined { + if (section === undefined) return undefined; + const remap = (alias: string): string => renamedAliases.get(alias) ?? alias; + const nextDefault = section.defaultModel === undefined ? undefined : remap(section.defaultModel); + const nextLegacyDefault = section.model === undefined ? undefined : remap(section.model); + const effectiveDefault = nextDefault ?? nextLegacyDefault; + if (effectiveDefault !== undefined && !(effectiveDefault in survivingModels)) return null; + + let changed = nextDefault !== section.defaultModel || nextLegacyDefault !== section.model; + let nextPool: Record | undefined; + if (section.models !== undefined) { + nextPool = {}; + for (const [alias, description] of Object.entries(section.models)) { + const key = remap(alias); + if (!(key in survivingModels)) { + changed = true; + continue; + } + if (key !== alias) changed = true; + nextPool[key] = description; + } + if (Object.keys(nextPool).length === 0) { + nextPool = undefined; + changed = true; + } + } + if (!changed) return undefined; + return { ...section, defaultModel: nextDefault, model: nextLegacyDefault, models: nextPool }; } export function resolveSubagentBinding( config: IConfigService, flags: IFlagService, own: { modelAlias: string; thinkingLevel: string }, - requested?: SubagentModelChoice, -): { model: string; thinking?: string; displayModel: string } { - const secondary = resolveSecondaryModel(config, flags); - if (requested !== 'primary' && secondary?.model !== undefined) { - const model = - secondaryModelPatch(secondary) === undefined ? secondary.model : SECONDARY_DERIVED_MODEL_ID; - return { - model, - thinking: secondary.defaultEffort, - displayModel: subagentDisplayModel(config, model), - }; - } - return { - model: own.modelAlias, - thinking: own.thinkingLevel, - displayModel: subagentDisplayModel(config, own.modelAlias), - }; -} - -export function subagentDisplayModel( - config: IConfigService, - boundAlias: string, -): string { - if (boundAlias !== SECONDARY_DERIVED_MODEL_ID) return boundAlias; - return ( - config.get(SECONDARY_MODEL_SECTION)?.model ?? boundAlias - ); + requested?: string, +): { model: string; thinking?: string } { + const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID); + const section = config.get(SECONDARY_MODEL_SECTION); + if (enabled && section?.force === true) { + if (section.models !== undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'force' }, + }); + } + const forcedModel = section.defaultModel ?? section.model; + if (forcedModel === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } + if (requested !== undefined) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Invalid model "${requested}": [secondary_model].force is set, so every subagent binds "${forcedModel}" (omit the model parameter).`, + { details: { model: requested } }, + ); + } + return { model: forcedModel }; + } + if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) { + return { model: own.modelAlias, thinking: own.thinkingLevel }; + } + const pool = enabled ? resolveSubagentModelPool(config) : undefined; + if (pool === undefined) { + if (requested !== undefined) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Invalid model "${requested}": no [secondary_model.models] pool is configured, so subagents inherit the caller's model (pass "primary" or omit the model parameter).`, + { details: { model: requested } }, + ); + } + return { model: own.modelAlias, thinking: own.thinkingLevel }; + } + if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, { + details: { + section: SECONDARY_MODEL_SECTION, + field: 'models', + model: PRIMARY_SUBAGENT_MODEL_CHOICE, + }, + }); + } + const choice = requested ?? pool.defaultModel; + if (choice === undefined) { + throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, { + details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' }, + }); + } + if (!Object.hasOwn(pool.models, choice)) { + const available = [...Object.keys(pool.models), PRIMARY_SUBAGENT_MODEL_CHOICE]; + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `Invalid model "${choice}". Available models: ${available.join(', ')}.`, + { details: { model: choice, availableModels: available } }, + ); + } + return { model: choice }; } export function buildSubagentModelDescriptions( config: IConfigService, flags: IFlagService, callerModelAlias: string | undefined, - modelCatalog: IModelCatalog, ): string | undefined { - const secondary = resolveSecondaryModel(config, flags); - const secondaryModel = secondary?.model; - if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; - const boundSecondary = - secondaryModelPatch(secondary) === undefined ? secondaryModel : SECONDARY_DERIVED_MODEL_ID; - return [ - 'Available models (pass via model):', - `- secondary: ${secondaryModel} (default) — the configured secondary model; prefer it for routine subagent tasks${capabilitiesSuffix(resolvedCapabilities(modelCatalog, boundSecondary))}`, - `- primary: ${callerModelAlias} — the main model you are running on; use it for hard, quality-sensitive subagent tasks${capabilitiesSuffix(resolvedCapabilities(modelCatalog, callerModelAlias))}`, - ].join('\n'); -} - -const ADVERTISED_CAPABILITY_FLAGS = [ - 'image_in', - 'video_in', - 'audio_in', - 'thinking', - 'tool_use', - 'dynamically_loaded_tools', -] as const satisfies readonly (keyof ModelCapability)[]; - -function capabilitiesSuffix(capability: ModelCapability | undefined): string { - if (capability === undefined) return ''; - const names = ADVERTISED_CAPABILITY_FLAGS.filter((flag) => capability[flag] === true); - return `; capabilities: ${names.length === 0 ? 'none' : names.join(', ')}`; + if (!exposesSubagentModelChoice(config, flags)) return undefined; + const pool = resolveSubagentModelPool(config)!; + const lines = ['Available models (pass via model):']; + const defaultModel = pool.defaultModel; + const markersFor = (alias: string): string => { + const markers: string[] = []; + if (alias === defaultModel) markers.push('[default]'); + if (alias === callerModelAlias) markers.push('[main model]'); + return markers.length === 0 ? '' : ` ${markers.join(' ')}`; + }; + if (defaultModel !== undefined && Object.hasOwn(pool.models, defaultModel)) { + lines.push( + formatPoolLine(`${defaultModel}${markersFor(defaultModel)}`, pool.models[defaultModel]!), + ); + } + for (const [alias, description] of Object.entries(pool.models)) { + if (alias === defaultModel) continue; + lines.push(formatPoolLine(`${alias}${markersFor(alias)}`, description)); + } + const callerInPool = + callerModelAlias !== undefined && Object.hasOwn(pool.models, callerModelAlias); + lines.push( + `- ${PRIMARY_SUBAGENT_MODEL_CHOICE}${callerInPool ? ` (${callerModelAlias})` : ''}: the main model you are running on, bound with your current thinking level; use it for hard, quality-sensitive subagent tasks`, + ); + return lines.join('\n'); } -function resolvedCapabilities( - modelCatalog: IModelCatalog, - model: string, -): ModelCapability | undefined { - try { - return modelCatalog.get(model).capabilities; - } catch { - return undefined; - } +function formatPoolLine(label: string, description: string): string { + return description === '' ? `- ${label}` : `- ${label}: ${description}`; } export function stripSubagentModelParameter( @@ -213,22 +440,17 @@ export function wrapSubagentModelError( if (boundModel === callerModelAlias) return error; if (!isError2(error) || error.code !== ErrorCodes.CONFIG_INVALID) return error; if (error.details?.['model'] !== boundModel) return error; - const displayModel = - boundModel === SECONDARY_DERIVED_MODEL_ID - ? `the derived entry "${SECONDARY_DERIVED_MODEL_ID}"` - : `"${boundModel}"`; return new Error2( error.code, - `${error.message} (secondary model ${displayModel} comes from [secondary_model].model / ${SECONDARY_MODEL_ENV} — check that it names a valid [models] entry)`, + `${error.message} (subagent model "${boundModel}" comes from [secondary_model.models] — check that it names a valid [models] entry)`, { cause: error, name: error.name, details: { ...error.details, - secondaryModel: boundModel, - secondaryModelConfig: { - section: 'secondaryModel.model', - environment: SECONDARY_MODEL_ENV, + subagentModel: boundModel, + subagentModelConfig: { + section: 'secondary_model.models', }, }, }, diff --git a/packages/agent-core-v2/src/session/subagent/flag.ts b/packages/agent-core-v2/src/session/subagent/flag.ts index 67ec3795c08..9a0c7b3a5f3 100644 --- a/packages/agent-core-v2/src/session/subagent/flag.ts +++ b/packages/agent-core-v2/src/session/subagent/flag.ts @@ -2,8 +2,8 @@ * `subagent` domain — registers the `secondary-model` experimental flag * into `flag`. * - * Gates secondary-model selection for newly spawned subagents, including the - * agent-facing model choices and startup validation warning. Off by default; + * Gates the subagent model pool for newly spawned subagents, including the + * agent-facing model choices and startup pool validation. Off by default; * enable via `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL`, the master * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. */ diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 8947c89823a..997e04f3f72 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -21,8 +21,7 @@ * Wire shape note: the signals are still named `subagent.spawned / started / * completed / failed` and telemetry still tracks `subagent_created` so existing * session recordings and dashboards stay valid. The spawned signal also - * reports the child's display-normalized model alias (the derived secondary - * entry resolves to its base alias) and its effective thinking effort, so + * reports the child's bound model alias and its effective thinking effort, so * clients can render both at spawn instead of waiting for the first * `agent.status.updated` frame. */ diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts deleted file mode 100644 index 31017de1404..00000000000 --- a/packages/agent-core-v2/src/session/subagent/secondaryModelWarning.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * `subagent` domain — `ISessionSecondaryModelWarningService` contract: - * early validation of the configured secondary model. - * - * The secondary-model pointer (`[secondary_model]` / `KIMI_SECONDARY_MODEL`) - * is otherwise validated lazily at spawn time, so a typo surfaces as a - * mid-conversation tool failure handed back to the parent model. This service - * front-loads the same resolution to session start (main-agent creation): an - * unresolvable model or an effort the model does not list becomes a `warning` - * event on the main agent's event bus, and stays cached for the edge to pull. - * A mid-session `[secondary_model]` change refreshes the cache through - * `recheckSecondaryModelWarning`. Session-scoped — one instance per session. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export const SECONDARY_MODEL_INVALID_WARNING_CODE = 'secondary-model-invalid'; -export const SECONDARY_MODEL_EFFORT_WARNING_CODE = 'secondary-model-effort-not-listed'; - -export interface SecondaryModelWarning { - readonly code: string; - readonly message: string; -} - -export interface ISessionSecondaryModelWarningService { - readonly _serviceBrand: undefined; - getSecondaryModelWarning(): SecondaryModelWarning | undefined; - recheckSecondaryModelWarning(): SecondaryModelWarning | undefined; -} - -export const ISessionSecondaryModelWarningService: ServiceIdentifier = - createDecorator('sessionSecondaryModelWarningService'); diff --git a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts b/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts deleted file mode 100644 index 16e8f4250fb..00000000000 --- a/packages/agent-core-v2/src/session/subagent/secondaryModelWarningService.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * `subagent` domain — `ISessionSecondaryModelWarningService` implementation. - * - * When enabled through `flag`, runs the secondary-model check once per session - * when the main agent appears (`agentLifecycle` onDidCreate, or an - * already-present main at construction): - * resolves the pointed entry through the kosong `modelCatalog` and, when the - * recipe carries patch fields, checks `default_effort` against the patched - * `supportEfforts` (what the derived entry will carry) — on failure, caches a - * warning and publishes it as a `warning` event on the main agent's - * `eventBus`, and stays cached for the edge to pull. - * `recheckSecondaryModelWarning` recomputes - * the cache after a mid-session `[secondary_model]` change, re-publishing - * only when the warning actually changed. Never throws: a broken secondary - * model demotes to a notice here, with spawn-time resolution staying as the - * backstop. Bound at Session scope. - */ - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { - type IAgentScopeHandle, - ScopeActivation, - registerScopedService, -} from '#/_base/di/scope'; -import { IConfigService } from '#/app/config/config'; -import { IEventBus } from '#/app/event/eventBus'; -import { IFlagService } from '#/app/flag/flag'; -import { - SECONDARY_MODEL_EFFORT_ENV, - SECONDARY_MODEL_ENV, -} from '#/app/kosongConfig/configSection'; -import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { secondaryModelPatch } from '#/app/kosongConfig/secondaryModelOverlay'; -import { normalizeRequestedThinkingEffort } from '#/kosong/model/thinking'; -import { - IAgentLifecycleService, - MAIN_AGENT_ID, -} from '#/session/agentLifecycle/agentLifecycle'; - -import { resolveSecondaryModel } from './configSection'; -import { - ISessionSecondaryModelWarningService, - SECONDARY_MODEL_EFFORT_WARNING_CODE, - SECONDARY_MODEL_INVALID_WARNING_CODE, - type SecondaryModelWarning, -} from './secondaryModelWarning'; - -// NOTE: stays Disposable — its own 'config' collides with the Fiber -export class SessionSecondaryModelWarningService - extends Disposable - implements ISessionSecondaryModelWarningService -{ - declare readonly _serviceBrand: undefined; - - private warning: SecondaryModelWarning | undefined; - private checked = false; - - constructor( - @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, - @IConfigService private readonly config: IConfigService, - @IFlagService private readonly flags: IFlagService, - @IModelCatalog private readonly modelCatalog: IModelCatalog, - ) { - super(); - this._register( - this.agentLifecycle.onDidCreate((handle) => { - if (handle.id === MAIN_AGENT_ID) this.check(handle); - }), - ); - const main = this.agentLifecycle.get(MAIN_AGENT_ID); - if (main !== undefined) this.check(main); - } - - getSecondaryModelWarning(): SecondaryModelWarning | undefined { - return this.warning; - } - - recheckSecondaryModelWarning(): SecondaryModelWarning | undefined { - const previous = this.warning; - this.warning = this.computeWarning(); - const changed = - previous?.code !== this.warning?.code || previous?.message !== this.warning?.message; - if (changed && this.warning !== undefined) { - this.agentLifecycle - .get(MAIN_AGENT_ID) - ?.accessor.get(IEventBus) - .publish({ - type: 'warning', - code: this.warning.code, - message: this.warning.message, - }); - } - return this.warning; - } - - private check(main: IAgentScopeHandle): void { - if (this.checked) return; - this.checked = true; - this.warning = this.computeWarning(); - if (this.warning !== undefined) { - main.accessor.get(IEventBus).publish({ - type: 'warning', - code: this.warning.code, - message: this.warning.message, - }); - } - } - - private computeWarning(): SecondaryModelWarning | undefined { - const secondary = resolveSecondaryModel(this.config, this.flags); - if (secondary?.model === undefined) return undefined; - let model: Model; - try { - model = this.modelCatalog.get(secondary.model); - } catch (error) { - return { - code: SECONDARY_MODEL_INVALID_WARNING_CODE, - message: - `Secondary model "${secondary.model}" (from [secondary_model].model / ${SECONDARY_MODEL_ENV}) ` + - `could not be resolved: ${error instanceof Error ? error.message : String(error)}. ` + - 'Subagent spawning will fail until this is fixed.', - }; - } - const patch = secondaryModelPatch(secondary); - return effortWarning( - secondary.model, - secondary.defaultEffort, - patch?.supportEfforts ?? model.supportEfforts, - ); - } -} - -function effortWarning( - alias: string, - effort: string | undefined, - supportEfforts: readonly string[] | undefined, -): SecondaryModelWarning | undefined { - const requested = normalizeRequestedThinkingEffort(effort); - if (requested === undefined || requested === 'off' || requested === 'on') return undefined; - const known = (supportEfforts ?? []) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - if (known.length === 0 || known.includes(requested)) return undefined; - return { - code: SECONDARY_MODEL_EFFORT_WARNING_CODE, - message: - `Secondary model default effort "${requested}" (from [secondary_model].default_effort / ${SECONDARY_MODEL_EFFORT_ENV}) ` + - `is not listed for model "${alias}" (known: ${known.join(', ')}). ` + - 'Subagents may clamp or reject it.', - }; -} - -registerScopedService( - LifecycleScope.Session, - ISessionSecondaryModelWarningService, - SessionSecondaryModelWarningService, - ScopeActivation.OnScopeCreated, - 'subagent', -); diff --git a/packages/agent-core-v2/src/session/subagent/subagentModelsValidation.ts b/packages/agent-core-v2/src/session/subagent/subagentModelsValidation.ts new file mode 100644 index 00000000000..1e158d9a5ec --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/subagentModelsValidation.ts @@ -0,0 +1,24 @@ +/** + * `subagent` domain — `ISessionSubagentModelsValidationService` contract: + * startup validation of the configured subagent model pool. + * + * The pool is primarily validated before session materialization by the + * session lifecycle (see `workspace/sessionLifecycle`); this service repeats + * the same check at Session-scope activation as a backstop, so a pool with a + * missing/out-of-pool `default_model`, a reserved `primary` key, or an + * unresolvable alias fails the session with `Error2(CONFIG_INVALID)` instead + * of degrading into a mid-conversation tool failure handed back to the + * parent model. Session-scoped — one instance per session; the contract + * carries no methods because the validation is the construction side effect. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISessionSubagentModelsValidationService { + readonly _serviceBrand: undefined; +} + +export const ISessionSubagentModelsValidationService: ServiceIdentifier = + createDecorator( + 'sessionSubagentModelsValidationService', + ); diff --git a/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts new file mode 100644 index 00000000000..6163ba92730 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts @@ -0,0 +1,46 @@ +/** + * `subagent` domain — `ISessionSubagentModelsValidationService` implementation. + * + * Backstop for the session lifecycle's pre-materialization check: validates + * the configured subagent model section (`[secondary_model.models]` + + * `[secondary_model].default_model`, plus the `force` rules) once per session + * at scope construction (`ScopeActivation.OnScopeCreated`), so a broken pool + * or forced model fails session creation with `Error2(CONFIG_INVALID)` even + * on paths that bypass the lifecycle service. Reads the section through + * `config` and resolves aliases through the model catalog — a lone + * `default_model` included, as the implicit single-entry pool; a session + * with neither pool nor force, or running with the `secondary-model` + * experiment off, is a no-op. The checks themselves live in + * `assertValidSubagentModelConfig` (configSection). Bound at Session scope. + */ + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { IModelCatalog } from '#/kosong/model/catalog'; + +import { assertValidSubagentModelConfig } from './configSection'; +import { ISessionSubagentModelsValidationService } from './subagentModelsValidation'; + +export class SessionSubagentModelsValidationService + implements ISessionSubagentModelsValidationService +{ + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService config: IConfigService, + @IFlagService flags: IFlagService, + @IModelCatalog modelCatalog: IModelCatalog, + ) { + assertValidSubagentModelConfig(config, flags, modelCatalog); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionSubagentModelsValidationService, + SessionSubagentModelsValidationService, + ScopeActivation.OnScopeCreated, + 'subagent', +); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts index 017612c37a9..d3a332d9035 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts @@ -14,20 +14,23 @@ * workspace and fork never crosses handlers. Announces lifecycle transitions * through `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` * / `onDidForkSession`; the ordered hook slots are per-session seeds. + * Workspace-scope services that must participate in a session's creation + * (read its seeded facts, contribute a session seed, attach teardown to its + * lifetime) subscribe to `onWillCreateSession` — the participation surface + * speaks the session domain's own vocabulary, so the lifecycle depends on + * neither its participants nor the DI kernel's assembly mechanics. * Workspace-scoped — one instance per materialized handler. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ISessionScopeHandle } from '#/_base/di/scope'; -import type { Event } from '#/_base/event'; +import { type Event, type IWaitUntil } from '#/_base/event'; import type { BindAgentInput } from '#/agent/profile/profile'; import type { McpServerConfig } from '#/mcpCore/config-schema'; -import type { - SessionCloseReason, - SessionCreateSource, -} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -export type { SessionCloseReason, SessionCreateSource }; +export type SessionCreateSource = 'startup' | 'resume' | 'fork'; + +export type SessionCloseReason = 'exit' | 'archive'; export interface CreateSessionOptions { readonly sessionId?: string; @@ -94,10 +97,33 @@ export interface SessionForkedEvent { readonly handle: ISessionScopeHandle; } +/** + * Participation surface of `onWillCreateSession` — the business-lifecycle + * moment "a session is being created", fired synchronously before the new + * session's services activate (the `will` half of `onDidCreateSession`; + * resume and fork are creations too). Workspace-scope participants step + * into the creation through the session domain's own vocabulary — read the + * session's seeded facts (`readSeed`), contribute or replace a session seed + * (`contributeSeed`; a seed already projected by the workspace seed + * adapters is replaced), and attach teardown work to the session's lifetime + * (`onSessionDispose` — runs with the session's teardown on every path: + * close, archive, delete, a failed create, workspace teardown). The event + * carries only facts the lifecycle itself owns; anything a participant + * needs beyond them travels as a session-domain seed. + */ +export interface SessionWillCreateEvent { + readonly sessionId: string; + readSeed(id: ServiceIdentifier): T; + contributeSeed(id: ServiceIdentifier, value: T): void; + onSessionDispose(dispose: () => void): void; +} + export interface ISessionLifecycleService { readonly _serviceBrand: undefined; - readonly onDidCreateSession: Event; + readonly onWillCreateSession: Event; + readonly onDidCreateSession: Event; + readonly onWillCloseSession: Event; readonly onDidCloseSession: Event; readonly onDidArchiveSession: Event; readonly onDidForkSession: Event; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index fb196abcaca..604e17807ea 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -25,7 +25,7 @@ * watching and connecting all live on the Workspace-scope services; session * consumers read the seeds and refresh off their change events. The five * workspace-projection seeds are provided by the seed-adapter units - * assembled with the scope (`assembleSessionSeedAdapters`), not by `extra`. + * installed with the scope (`installSessionSeedAdapters`), not by `extra`. * Materializes the session's initial metadata on * creation. Bound at Workspace scope. * Persisted sessions are discovered through the session-index read model. @@ -38,6 +38,11 @@ * live Agent wire journals, normalizes a missing protocol envelope, and * appends the fork boundary before restoring the target Agent; fork is * confined to this handler (source and target share the workspace bucket). + * Fork restores the source's recency onto the target: the metadata write + * carries an explicit `updatedAt` and runs after agent recreation as the + * fork's final metadata write (agent registration is non-touching), ahead + * of cron duplication, so a mid-fork failure never leaves cloned cron + * records behind. * On * materialize, the agent-profile loaders' `ready` is awaited * before the handle is published — agent-file discovery is local- @@ -53,14 +58,32 @@ * returns — it connects fire-and-forget at Workspace scope, and the seeded * handle's `ready` promise lets the agent's LLM steps wait on it instead * (see `AgentMcpService`). A session created with ephemeral `mcpServers` - * additionally gets a session overlay from `workspaceMcp` (session-owned - * connections, seeded as a merged view, shut down when the session handle - * disposes — with a backstop in the service's own dispose for teardown - * paths that bypass the handle wrapper), likewise connected in the - * background. + * gets them seeded verbatim (`ISessionEphemeralMcpServers`); connecting + * them is the MCP domain's own concern — `workspaceMcp` subscribes to this + * service's `onWillCreateSession`, reads the session's seeds through the + * event's session-domain surface (`readSeed` / `contributeSeed` / + * `onSessionDispose`), contributes its session overlay handle, and attaches + * the overlay's shutdown to the session's teardown, so this service never + * depends on MCP. * The session-level services whose subscriptions * must exist before the first agent / turn (external hooks, cron, the - * secondary-model startup warning) opt into `OnScopeCreated` activation. + * subagent model-pool startup validation) opt into `OnScopeCreated` activation. + * The subagent model pool itself is validated even earlier — at + * the top of `materializeSession`, before the MCP overlay, the session scope, + * and any persisted artifact come into existence, and again at the top of + * `fork` before the source session's files are copied — so a broken pool + * (or invalid `force` configuration) fails create/resume/fork without + * leaving orphaned session dirs or leaked + * overlay connections behind; the Session-scope validation service + * (`session/subagent/subagentModelsValidationService.ts`) repeats the same + * check at scope activation as a backstop for paths that bypass this service. + * That pre-flight awaits the kosong model/provider registries' `ready` + * alongside `config.ready` first: the catalog resolves aliases through those + * registries rather than the config document, so a cold bootstrap that + * creates a session before hydration completes must not fail a valid pool + * with `CONFIG_INVALID`. + * The pool is gated behind the `secondary-model` experiment, so with the + * experiment off these validations are no-ops and the section stays inert. */ import { randomUUID } from 'node:crypto'; @@ -78,7 +101,7 @@ import { registerScopedService, } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; -import { Emitter, type Event } from '#/_base/event'; +import { AsyncEmitter, Emitter, type Event, type IWaitUntil } from '#/_base/event'; import { DEFAULT_PLAN_MODE_SECTION } from '#/features/plan/configSection'; import { IAgentPlanService } from '#/features/plan/plan'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -95,7 +118,6 @@ import { } from '#/app/sessionIndex/sessionIndex'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isError2 } from '#/errors'; -import { createHooks } from '#/hooks'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostDirEntry } from '#/os/interface/hostFileSystem'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; @@ -104,15 +126,11 @@ import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/ import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; +import { sessionEphemeralMcpServersSeed } from '#/session/mcp/ephemeralMcpServers'; import { sessionAgentProfileCatalogSeed } from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; -import { assembleSessionSeedAdapters } from '#/session/sessionSeed/sessionSeedAdapters'; -import { - ISessionLifecycleHooks, - sessionLifecycleHooksSeed, - type SessionLifecycleHookSlots, -} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +import { installSessionSeedAdapters } from '#/session/sessionSeed/sessionSeedAdapters'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; -import { drainSessionMetadataWrites } from '#/session/sessionMetadata/sessionMetadataService'; +import { drainSessionMetadataWrites, toEpochMs } from '#/session/sessionMetadata/sessionMetadataService'; import { ISessionProcessRunner } from '#/session/process/processRunner'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { IWireService } from '#/wire/wire'; @@ -121,6 +139,11 @@ import { createWireMetadataRecord, type WireRecord, } from '#/wire/record'; +import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; +import { IFlagService } from '#/app/flag/flag'; +import { assertValidSubagentModelConfig } from '#/session/subagent/configSection'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; @@ -134,10 +157,6 @@ import { IWorkspaceAgentProfileLoader, } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { - IWorkspaceMcpService, - type ISessionMcpOverlay, -} from '#/workspace/workspaceMcp/workspaceMcp'; import { agentScopeOf, sessionDirOf, sessionScopeOf } from './internal/addressing'; import { @@ -150,6 +169,7 @@ import { type SessionCreatedEvent, type SessionForkedEvent, type SessionWillCloseEvent, + type SessionWillCreateEvent, ISessionLifecycleService, } from './sessionLifecycle'; @@ -157,12 +177,27 @@ type MaterializeSessionOptions = Omit & { readonly sessionId: string; }; +const NO_ABORT = new AbortController().signal; + // NOTE: stays Disposable — its own 'get' and 'config' collide with the Fiber export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); - private readonly _onDidCreateSession = this._register(new Emitter()); - readonly onDidCreateSession: Event = this._onDidCreateSession.event; + private readonly _onWillCreateSession = this._register( + new Emitter(), + ); + readonly onWillCreateSession: Event = + this._onWillCreateSession.event; + private readonly _onDidCreateSession = this._register( + new AsyncEmitter(), + ); + readonly onDidCreateSession: Event = + this._onDidCreateSession.event; + private readonly _onWillCloseSession = this._register( + new AsyncEmitter(), + ); + readonly onWillCloseSession: Event = + this._onWillCloseSession.event; private readonly _onDidCloseSession = this._register(new Emitter()); readonly onDidCloseSession: Event = this._onDidCloseSession.event; private readonly _onDidArchiveSession = this._register(new Emitter()); @@ -170,14 +205,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); - /** - * Live per-session MCP overlays keyed by session id. The session handle's - * dispose removes its overlay here before shutting it down, so whatever - * remains at service teardown (the DI container disposes session scopes - * directly, bypassing the handle wrapper) is shut down from the - * service's own dispose instead — no overlay outlives the lifecycle. - */ - private readonly liveOverlays = new Map(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -203,21 +230,14 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly userAgentProfileLoader: IUserAgentProfileLoader, @IPluginAgentProfileLoader private readonly pluginAgentProfileLoader: IPluginAgentProfileLoader, - @IWorkspaceMcpService private readonly mcp: IWorkspaceMcpService, @IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IModelService private readonly models: IModelService, + @IProviderService private readonly providers: IProviderService, + @IFlagService private readonly flags: IFlagService, ) { super(); - this._register({ - dispose: () => { - // Service teardown (e.g. workspace/root scope disposal) bypasses the - // per-session handle wrappers — shut down every overlay still live. - for (const overlay of this.liveOverlays.values()) { - void overlay.shutdown(); - } - this.liveOverlays.clear(); - }, - }); } private get workspaceId(): string { @@ -256,11 +276,17 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return handle; } + private async assertSubagentModelPoolPreFlight(): Promise { + await Promise.all([this.config.ready, this.models.ready, this.providers.ready]); + assertValidSubagentModelConfig(this.config, this.flags, this.modelCatalog); + } + private async materializeSession(opts: MaterializeSessionOptions): Promise { const workspaceId = this.workspaceId; const sessionScope = sessionScopeOf(this.handlerScope, opts.sessionId); const sessionDir = sessionDirOf(this.bootstrap.homeDir, this.handlerScope, opts.sessionId); const metaScope = sessionScope; + await this.assertSubagentModelPoolPreFlight(); await this.workspaceDirs.ready; await this.workspaceDirs.mergeAdditionalDirs(opts.workDir, opts.additionalDirs ?? []); const ctx: ISessionContext = { @@ -273,51 +299,40 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }; - const hooks = createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]); await this.hostEnv.ready; - const mcpOverlay = - opts.mcpServers !== undefined && Object.keys(opts.mcpServers).length > 0 - ? this.mcp.sessionOverlay(opts.mcpServers, { stdioCwd: opts.workDir }) - : undefined; - if (mcpOverlay !== undefined) { - this.liveOverlays.set(opts.sessionId, mcpOverlay); - } - const scopeHandle = createScopedChildHandle( + const handle = createScopedChildHandle( this.instantiation, LifecycleScope.Session, opts.sessionId, { - extra: [ + seeds: [ ...sessionContextSeed(ctx), - ...sessionLifecycleHooksSeed(hooks), [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], ...sessionAgentProfileCatalogSeed({ _serviceBrand: undefined, workspaceKey: workspaceId, }), [ISessionProcessRunner, this.processRunner], + ...sessionEphemeralMcpServersSeed(opts.mcpServers ?? {}), ], - assemble: (container) => assembleSessionSeedAdapters(container, mcpOverlay?.handle), + configureContainer: (container) => { + installSessionSeedAdapters(container); + // The will-create moment is a business-lifecycle event; the DI + // container behind the participation surface stays this service's + // implementation detail. + this._onWillCreateSession.fire({ + sessionId: opts.sessionId, + readSeed: (id) => container.invokeFunction((accessor) => accessor.get(id)), + contributeSeed: (id, value) => { + container.provide(id, value); + }, + onSessionDispose: (dispose) => { + container.anchorKernelEntry(dispose, 'sessionLifecycle:willCreateParticipant'); + }, + }); + }, }, ) as ISessionScopeHandle; - const handle: ISessionScopeHandle = - mcpOverlay === undefined - ? scopeHandle - : { - ...scopeHandle, - dispose: () => { - // Delete-then-shutdown is atomic (single-threaded): the service - // teardown path only shuts down overlays still in the map, so a - // handle dispose and a service dispose can never double-shutdown. - if (this.liveOverlays.delete(opts.sessionId)) { - void mcpOverlay.shutdown(); - } - scopeHandle.dispose(); - }, - }; try { await handle.accessor.get(ISessionMetadata).ready; await handle.accessor.get(ISessionToolPolicy).ready; @@ -348,10 +363,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async announceCreated(event: SessionCreatedEvent): Promise { - await event.handle.accessor - .get(ISessionLifecycleHooks) - .onDidCreateSession.run({ source: event.source }); - this._onDidCreateSession.fire(event); + await this._onDidCreateSession.fireAsync(event, NO_ABORT); event.handle.accessor .get(ITelemetryService) .track2('session_started', { resumed: event.source === 'resume' }); @@ -475,9 +487,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async announceWillClose(event: SessionWillCloseEvent): Promise { - await event.handle.accessor - .get(ISessionLifecycleHooks) - .onWillCloseSession.run({ reason: event.reason }); + await this._onWillCloseSession.fireAsync(event, NO_ABORT); } private async drainAgents(handle: ISessionScopeHandle): Promise { @@ -503,6 +513,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; try { + await this.assertSubagentModelPoolPreFlight(); // A turn that just ended may still have its outcome write queued; // settle pending metadata writes before reading the source for // inheritance, or the fork could copy a stale (or absent) outcome. @@ -545,11 +556,22 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; + + for (const agentId of agentIds) { + const sourceAgent = sourceAgents[agentId]!; + await target.accessor.get(IAgentLifecycleService).create({ + agentId, + forkedFrom: sourceAgent.forkedFrom, + labels: labelsFromAgentMeta(sourceAgent), + }); + } + await targetMeta.update({ title, - isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, + titleKind: opts.title !== undefined ? 'custom' : 'replaceable', forkedFrom: sourceId, archived: false, + updatedAt: toEpochMs(sourceMeta?.updatedAt) || Date.now(), lastPrompt: sourceMeta?.lastPrompt, // The fork continues the source's conversation, so it inherits the // last turn's outcome too — otherwise a restart would drop a failure @@ -560,15 +582,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.duplicateCronTasks(sourceId, targetId); - for (const agentId of agentIds) { - const sourceAgent = sourceAgents[agentId]!; - await target.accessor.get(IAgentLifecycleService).create({ - agentId, - forkedFrom: sourceAgent.forkedFrom, - labels: labelsFromAgentMeta(sourceAgent), - }); - } - await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd); this._onDidForkSession.fire({ sourceSessionId: sourceId, diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts index 6d5eaba3a0d..4b8a8871fd7 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts @@ -94,7 +94,6 @@ export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDef const rawSubagents = parseStringList(frontmatter['subagents'], 'subagents', options.path); const subagents = rawSubagents?.length === 1 && rawSubagents[0] === '*' ? undefined : rawSubagents; - const modelPreference = parseModelPreference(frontmatter['model_preference'], options.path); const prompt = parsed.body.trim(); if (prompt.length === 0) { @@ -109,24 +108,12 @@ export function parseAgentFileText(options: ParseAgentFileOptions): AgentFileDef tools, disallowedTools, subagents, - modelPreference, prompt, path: options.path, source: options.source, }; } -function parseModelPreference( - value: unknown, - filePath: string, -): AgentFileDefinition['modelPreference'] { - if (value === undefined || value === null) return undefined; - if (value === 'primary' || value === 'secondary') return value; - throw new AgentFileParseError( - `Frontmatter field "model_preference" in ${filePath} must be "primary" or "secondary"`, - ); -} - function parseBoolean(value: unknown, field: string, filePath: string): boolean { if (value === undefined || value === null) return false; if (typeof value === 'boolean') return value; diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts index 66089adb5ca..1e9cc2d36d0 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentProfileFromFile.ts @@ -8,8 +8,7 @@ * marked as builtin overrides; directory files must opt in through frontmatter. * `tools` passes through as the allowlist (`undefined` = every tool active); * `disallowedTools` passes through as the tool denylist; `subagents` passes - * through as the delegation allowlist; `model_preference` becomes the - * symbolic default model used when the profile is delegated to. + * through as the delegation allowlist. * `profilesFromDiscovery` packs a whole discovery pass into an * `AgentProfileContribution`, binding each profile's `${base_prompt}` * placeholder lazily at render time so it always reflects the effective @@ -45,7 +44,6 @@ export function agentProfileFromFile( tools: definition.tools, disallowedTools: definition.disallowedTools, subagents: definition.subagents, - modelPreference: definition.modelPreference, renderSystemPrompt: (context) => renderPromptTemplateResult(definition.prompt, context, { skillActive }, basePrompt), }); diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts index 7d0a8ab48c2..c09df234c49 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts @@ -5,8 +5,9 @@ * filesystem boundary. Pure path probes; no scoped state. */ -import { dirname, join, resolve } from 'pathe'; +import { join } from 'pathe'; +import { findUpwardRoot } from '#/_base/utils/paths'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; @@ -86,20 +87,15 @@ async function findProjectRoot( workDir: string, warn?: AgentRootWarn, ): Promise { - const start = resolve(workDir); - let current = start; - while (true) { - const marker = join(current, '.git'); + return findUpwardRoot(workDir, '.git', async (marker) => { try { - if (await pathExists(fs, marker)) return current; + return await pathExists(fs, marker); } catch (error) { if (isUnavailable(error)) throw error; warn?.(`Skipping unreadable project marker ${marker}: ${errorMessage(error)}`, error); + return false; } - const parent = dirname(current); - if (parent === current) return start; - current = parent; - } + }); } async function pushFirstExisting( diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts index 729653f012c..f34c7dfec74 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/types.ts @@ -7,7 +7,6 @@ * Pure data; no scoped state. */ -import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { SkippedAgentFile } from '#/app/agentProfileCatalog/agentProfileContribution'; export type { SkippedAgentFile } from '#/app/agentProfileCatalog/agentProfileContribution'; @@ -27,7 +26,6 @@ export interface AgentFileDefinition { readonly tools?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; - readonly modelPreference?: AgentModelPreference; readonly prompt: string; readonly path: string; readonly source: AgentFileSource; diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts index 220e3eac57e..a95695a63c8 100644 --- a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcp.ts @@ -11,8 +11,10 @@ * (`sessionOverlay()`): a session-owned manager for those servers — never * persisted, never part of the config domain's effective set, invisible to * the handler's other sessions — presented to the session through a merged - * view, and released by the caller (`shutdown()`) when the session scope - * tears down. Ephemeral servers are a caller-explicit injection channel + * view. The service activates overlays itself from the session lifecycle's + * `onWillCreateSession` event (keyed by the `ISessionEphemeralMcpServers` + * seed) and attaches each overlay's `shutdown()` to the session's teardown. + * Ephemeral servers are a caller-explicit injection channel * (like the user-level `mcp.json`), so they are not gated by workspace * trust — only the project-level config files are. Bound at Workspace scope. */ diff --git a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts index 6253a9bf4dd..efd515b62e1 100644 --- a/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceMcp/workspaceMcpService.ts @@ -21,8 +21,15 @@ * overlays (`sessionOverlay`): a session-owned manager for a session's * ephemeral (caller-injected, never persisted) servers — baseline members * by construction — presented through a - * `MergedMcpConnectionView` over the shared manager and shut down by the - * session lifecycle when the session scope tears down. An overlay handle's + * `MergedMcpConnectionView` over the shared manager. Overlay activation is + * event-driven: this service subscribes to the session lifecycle's + * `onWillCreateSession`, and a session created with an + * `ISessionEphemeralMcpServers` seed gets its overlay created there — the + * merged handle contributed as the session's `ISessionMcpHandle` (replacing + * the seed adapter's workspace projection), the overlay's shutdown attached + * to the session's teardown, so the session lifecycle never depends on MCP. + * The overlay's stdio cwd is read from the session's own `ISessionContext`. + * An overlay handle's * baseline still freezes on the workspace manager's initial load — never on * the overlay's own connect — so a slow ephemeral connect cannot reopen the * window for mid-session workspace additions. @@ -52,9 +59,12 @@ import { McpOAuthService } from '#/mcpCore/oauth/service'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IMcpOAuthStore } from '#/app/mcpConfig/oauthStore'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ISessionEphemeralMcpServers } from '#/session/mcp/ephemeralMcpServers'; import { MergedMcpConnectionView } from '#/session/mcp/mergedConnectionView'; -import type { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceMcpConfigService, type McpServersChange, @@ -83,6 +93,7 @@ export class WorkspaceMcpService extends Service implements IWorkspaceMcpService @ILogService private readonly log: ILogService, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentIdentity private readonly identity: IAgentIdentity, + @ISessionLifecycleService sessionLifecycle: ISessionLifecycleService, ) { super(); this.stdioCwd = workspace.cwd; @@ -103,6 +114,19 @@ export class WorkspaceMcpService extends Service implements IWorkspaceMcpService this.scheduleApply(change); }), ); + this._register( + sessionLifecycle.onWillCreateSession((event) => { + const servers = event.readSeed(ISessionEphemeralMcpServers); + if (Object.keys(servers).length === 0) return; + const overlay = this.sessionOverlay(servers, { + stdioCwd: event.readSeed(ISessionContext).cwd, + }); + event.contributeSeed(ISessionMcpHandle, overlay.handle); + event.onSessionDispose(() => { + void overlay.shutdown(); + }); + }), + ); this.ready = this.initialize().catch((error: unknown) => { this.log.error('mcp initial load failed', { error }); }); diff --git a/packages/agent-core-v2/test/_base/di/scope-tree.test.ts b/packages/agent-core-v2/test/_base/di/scope-tree.test.ts index 6e9290863e3..77c5b06d36d 100644 --- a/packages/agent-core-v2/test/_base/di/scope-tree.test.ts +++ b/packages/agent-core-v2/test/_base/di/scope-tree.test.ts @@ -159,7 +159,7 @@ describe('Scope tree', () => { app.dispose(); }); - it('extra seed injects a context token resolvable from that scope', () => { + it('seeds inject a context token resolvable from that scope', () => { interface ISessionContext { sessionId: string; } @@ -168,7 +168,7 @@ describe('Scope tree', () => { const app = createAppScope(); const session = app.createChild(LifecycleScope.Session, 's1', { - extra: [[ISessionContext as ServiceIdentifier, { sessionId: 's1' }]], + seeds: [[ISessionContext as ServiceIdentifier, { sessionId: 's1' }]], }); expect(session.accessor.get(ISessionContext).sessionId).toBe('s1'); expect(() => app.accessor.get(ISessionContext)).toThrow(); diff --git a/packages/agent-core-v2/test/_base/event.test.ts b/packages/agent-core-v2/test/_base/event.test.ts index 08d175c254b..8078a2e7be3 100644 --- a/packages/agent-core-v2/test/_base/event.test.ts +++ b/packages/agent-core-v2/test/_base/event.test.ts @@ -168,6 +168,44 @@ describe('Event.None', () => { }); }); +describe('Emitter debug name / EventSubscription ledger labels', () => { + it('named emitter subscriptions land on the store ledger as on:', () => { + const emitter = new Emitter('test.event'); + const store = new DisposableStore(); + + emitter.event(() => undefined, undefined, store); + + expect(store.ledger.entries().map((entry) => entry.label)).toContain('on:test.event'); + store.dispose(); + emitter.dispose(); + }); + + it('unnamed emitter subscriptions fall back to disposable:EventSubscription', () => { + const emitter = new Emitter(); + const store = new DisposableStore(); + + emitter.event(() => undefined, undefined, store); + + expect(store.ledger.entries().map((entry) => entry.label)).toContain( + 'disposable:EventSubscription', + ); + store.dispose(); + emitter.dispose(); + }); + + it('listenerCount tracks subscribe and dispose', () => { + const emitter = new Emitter(); + expect(emitter.listenerCount).toBe(0); + + const subscription = emitter.event(() => undefined); + expect(emitter.listenerCount).toBe(1); + + subscription.dispose(); + expect(emitter.listenerCount).toBe(0); + emitter.dispose(); + }); +}); + describe('Event.once', () => { it('delivers exactly once then auto-disposes', () => { const emitter = new Emitter(); diff --git a/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts b/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts index 9e084a0ebc5..7cc4b7f2252 100644 --- a/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts +++ b/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts @@ -20,6 +20,7 @@ import { describe, expect, it } from 'vitest'; import { probeHostEnvironment, + ProbeShellNotFoundError, type HostEnvironmentProbeDeps, } from '#/_base/execEnv/environmentProbe'; @@ -96,4 +97,20 @@ describe('probeHostEnvironment', () => { expect(env.shellName).toBe('bash'); expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); }); + + it('throws ProbeShellNotFoundError when Git Bash is missing on Windows', async () => { + const rejected: unknown = await probeHostEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Windows\\System32' }, + existingPaths: [], + }), + ).catch((error: unknown) => error); + + expect(rejected).toBeInstanceOf(ProbeShellNotFoundError); + const probeError = rejected as ProbeShellNotFoundError; + expect(probeError.message).toContain('https://gitforwindows.org/'); + expect(probeError.message).not.toContain('Checked:'); + expect(probeError.checked.length).toBeGreaterThan(0); + }); }); diff --git a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts index 8ff98d2225c..6709a9114c6 100644 --- a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts +++ b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts @@ -52,6 +52,52 @@ describe('StateRegistry', () => { expect(() => registry.register(countKey)).toThrow(BugIndicatingError); }); + it('removes the key and value when its registration is disposed', () => { + const registry = new StateRegistry(); + const registration = registry.register(countKey); + registry.set(countKey, 42); + + registration.dispose(); + + expect(registry.has(countKey)).toBe(false); + expect(registry.entries()).toEqual([]); + expect(() => registry.get(countKey)).toThrow(BugIndicatingError); + expect(() => registry.set(countKey, 1)).toThrow(BugIndicatingError); + }); + + it('re-registers with the initial value and ignores stale disposal', () => { + const registry = new StateRegistry(); + const first = registry.register(countKey); + registry.set(countKey, 42); + first.dispose(); + + const second = registry.register(countKey); + expect(registry.get(countKey)).toBe(0); + + first.dispose(); + expect(registry.has(countKey)).toBe(true); + second.dispose(); + expect(registry.has(countKey)).toBe(false); + }); + + it('isolates listeners between registrations', () => { + const registry = new StateRegistry(); + const first = registry.register(countKey); + const oldSeen: number[] = []; + registry.onDidChange(countKey)((value) => oldSeen.push(value)); + registry.set(countKey, 1); + first.dispose(); + + const second = registry.register(countKey); + const newSeen: number[] = []; + registry.onDidChange(countKey)((value) => newSeen.push(value)); + registry.set(countKey, 2); + + expect(oldSeen).toEqual([1]); + expect(newSeen).toEqual([2]); + second.dispose(); + }); + it('rejects get and set on an unregistered key', () => { const registry = new StateRegistry(); expect(() => registry.get(countKey)).toThrow(BugIndicatingError); diff --git a/packages/agent-core-v2/test/_base/utils/paths.test.ts b/packages/agent-core-v2/test/_base/utils/paths.test.ts index 1f73ebc59da..532c6d9f78e 100644 --- a/packages/agent-core-v2/test/_base/utils/paths.test.ts +++ b/packages/agent-core-v2/test/_base/utils/paths.test.ts @@ -1,14 +1,19 @@ /** * Scenario: recursive watches constrained to selected candidate subtrees. - * Responsibilities: candidate ancestry, scan-depth bounds, and excluded-entry - * probing. Wiring: pure path predicates with no external collaborators. + * Responsibilities: candidate ancestry, scan-depth bounds, excluded-entry + * probing, and the marker-based upward root walk. Wiring: pure path + * predicates and walks with no external collaborators. * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run * test/_base/utils/paths.test.ts`. */ -import { describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import nodePath, { win32 } from 'node:path'; -import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { findUpwardRoot, subtreeWatchFilter } from '#/_base/utils/paths'; describe('subtree watch filtering', () => { const root = '/repo'; @@ -105,3 +110,70 @@ describe('subtree watch filtering', () => { expect(ignored('/repo/.agents/skills/parent/child/runtime')).toBe(true); }); }); + +describe('findUpwardRoot', () => { + const noMarker = async () => false; + + describe('with host-default path semantics', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(nodePath.join(tmpdir(), 'upward-root-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + const hasMarker = async (markerPath: string): Promise => { + try { + await stat(markerPath); + return true; + } catch { + return false; + } + }; + + it('stops at the nearest ancestor holding the marker', async () => { + await mkdir(nodePath.join(root, '.git')); + const child = nodePath.join(root, 'src', 'pkg'); + await mkdir(child, { recursive: true }); + + const found = await findUpwardRoot(child, '.git', hasMarker); + + expect(found).toBe(root.replaceAll('\\', '/')); + }); + + it('falls back to the working directory when no ancestor holds the marker', async () => { + const child = nodePath.join(root, 'src', 'pkg'); + await mkdir(child, { recursive: true }); + + const found = await findUpwardRoot(child, '.git', hasMarker); + + expect(found).toBe(child.replaceAll('\\', '/')); + }); + }); + + it('keeps a Windows drive-root working directory in host form', async () => { + const found = await findUpwardRoot('E:\\', '.git', noMarker, win32); + + expect(found).toBe('E:/'); + }); + + it('keeps a Windows UNC working directory in host form', async () => { + const found = await findUpwardRoot('\\\\fs1\\share\\dir', '.git', noMarker, win32); + + expect(found).toBe('//fs1/share/dir'); + }); + + it('stops at the nearest Windows ancestor holding the marker', async () => { + const found = await findUpwardRoot( + 'E:\\repo\\src', + '.git', + async (markerPath) => markerPath === 'E:\\repo\\.git', + win32, + ); + + expect(found).toBe('E:/repo'); + }); +}); diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index b9309eb3dbe..6dd7cf4a3f3 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -1,6 +1,6 @@ /** * Scenario: discover uninjected AGENTS.md files from canonical tool accesses and Bash targets. - * Responsibilities: seeding, once-only reminders, result delivery, probing, and path extraction. + * Responsibilities: seeding, once-only reminders, queue delivery, probing, and path extraction. * Wiring: real reminder, executor, parser, and host filesystem with telemetry/event stubs. * Run: pnpm exec vitest run test/agent/agentsMdReminder/agentsMdReminder.test.ts */ @@ -46,12 +46,11 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; import { AgentToolDedupeService } from '#/agent/toolDedupe/toolDedupeService'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import type { PromptOrigin } from '#/agent/contextMemory/types'; import { OrderedHookSlot } from '#/hooks'; import { IWireService } from '#/wire/wire'; -import type { - ResolvedToolExecutionHookContext, - ToolDidExecuteContext, -} from '#/agent/toolExecutor/toolHooks'; +import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { AgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminderService'; import { extractBashTargetDirs } from '#/agent/agentsMdReminder/bashTargets'; @@ -77,12 +76,18 @@ afterEach(async () => { await rm(workDir, { recursive: true, force: true }); }); +interface CapturedReminder { + readonly content: string; + readonly origin: PromptOrigin; +} + interface Harness { readonly ix: TestInstantiationService; readonly events: ToolExecutorEventStubs; readonly reminder: IAgentAgentsMdReminderService; readonly wire: IWireService; readonly telemetryEvents: TelemetryRecord[]; + readonly reminders: CapturedReminder[]; } function createHarness( @@ -100,6 +105,7 @@ function createHarness( } = {}, ): Harness { const telemetryEvents: TelemetryRecord[] = []; + const reminders: CapturedReminder[] = []; const events = stubToolExecutorEvents(); const ix = createServices(disposables, { additionalServices: (reg) => { @@ -137,6 +143,13 @@ function createHarness( reg.defineInstance(IWireService, wire); reg.defineInstance(IBootstrapService, { homeDir } as unknown as IBootstrapService); reg.defineInstance(IAgentStateService, new AgentStateService()); + reg.defineInstance(IAgentSystemReminderService, { + _serviceBrand: undefined, + appendSystemReminder: (content: string, origin: PromptOrigin) => { + reminders.push({ content, origin }); + return { role: 'user', content: [], toolCalls: [], origin }; + }, + } satisfies IAgentSystemReminderService); reg.defineInstance(ISessionContext, { _serviceBrand: undefined, sessionId: 'session-1', @@ -168,7 +181,7 @@ function createHarness( }); const reminder = ix.get(IAgentAgentsMdReminderService); const wire = ix.get(IWireService); - return { ix, events, reminder, wire, telemetryEvents }; + return { ix, events, reminder, wire, telemetryEvents, reminders }; } function didCtx( @@ -215,23 +228,6 @@ function testAccesses(name: string, args: unknown): ToolAccessesType | undefined return undefined; } -function willCtx(id: string, name: string, args: unknown): ResolvedToolExecutionHookContext { - const toolCall: ToolCall = { - type: 'function', - id, - name, - arguments: JSON.stringify(args), - }; - return { - turnId: 1, - signal: new AbortController().signal, - toolCall, - toolCalls: [toolCall], - args, - execution: { approvalRule: 'x', execute: async () => ({ output: '' }) }, - }; -} - async function fire(h: Harness, ctx: ToolDidExecuteContext): Promise { await h.events.didExecuteSlot.run(ctx); return ctx.result; @@ -246,6 +242,10 @@ function outputText(result: ExecutableToolResult): string { .join(''); } +function reminderText(h: Harness): string { + return h.reminders.map((entry) => entry.content).join('\n'); +} + async function writeAgentsMd(dir: string, content = 'instructions'): Promise { await mkdir(dir, { recursive: true }); const path = join(dir, 'AGENTS.md'); @@ -263,9 +263,14 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); - const text = outputText(result); - expect(text).toContain('original result'); - expect(text).toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(1); + expect(h.reminders[0]?.origin).toEqual({ kind: 'injection', variant: 'agents_md' }); + expect(h.reminders[0]?.content.startsWith('The path(s) touched by a recent tool call')).toBe( + true, + ); + expect(h.reminders[0]?.content).not.toContain(''); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -278,8 +283,10 @@ describe('agentsMdReminder path-carrying tools', () => { const first = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); const second = await fire(h, didCtx('Edit', { path: join(subDir, 'b.ts') })); - expect(outputText(first)).toContain(subAgentsMd); - expect(outputText(second)).not.toContain(''); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('marks an AGENTS.md known when read directly and never suggests it afterwards', async () => { @@ -288,10 +295,12 @@ describe('agentsMdReminder path-carrying tools', () => { const subAgentsMd = await writeAgentsMd(subDir); const direct = await fire(h, didCtx('Read', { path: subAgentsMd })); - expect(outputText(direct)).not.toContain(''); + expect(outputText(direct)).toBe('original result'); + expect(h.reminders).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); - expect(outputText(after)).not.toContain(subAgentsMd); + expect(outputText(after)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('discovers the .kimi-code/AGENTS.md variant alongside the plain one', async () => { @@ -303,7 +312,8 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(dotKimi); expect(text).toContain(plain); }); @@ -319,7 +329,8 @@ describe('agentsMdReminder path-carrying tools', () => { didCtx('Write', { path: join(workDir, 'new-pkg', 'src', 'index.ts'), content: 'x' }), ); - expect(outputText(result)).toContain(rootAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(rootAgentsMd); }); it('does not remind for seeded paths on the injected chain', async () => { @@ -329,7 +340,8 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Glob', { pattern: '**/*.ts' })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('tracks the shown event through telemetry', async () => { @@ -356,7 +368,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'ls packages/kap-server' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('rebases relative operands across a literal cd', async () => { @@ -365,7 +378,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'cd packages && ls kap-server' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('extracts find roots and stops at the expression', async () => { @@ -377,7 +391,8 @@ describe('agentsMdReminder Bash coverage', () => { didCtx('Bash', { command: "find packages/kap-server -name '*.ts'" }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('extracts quoted directory operands', async () => { @@ -386,7 +401,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'ls "packages/kap-server"' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('probes an explicit cwd even when the command lists nothing', async () => { @@ -398,7 +414,8 @@ describe('agentsMdReminder Bash coverage', () => { didCtx('Bash', { command: 'git status', cwd: 'packages/kap-server' }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('skips operands that are not statically resolvable', async () => { @@ -407,13 +424,14 @@ describe('agentsMdReminder Bash coverage', () => { for (const command of ['ls $DIR', 'ls *.ts', 'ls $(pwd)', 'echo packages/kap-server']) { const result = await fire(h, didCtx('Bash', { command })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); } + expect(h.reminders).toHaveLength(0); }); }); describe('agentsMdReminder result shapes and edge cases', () => { - it('prepends the reminder to the first text part of ContentPart[] outputs', async () => { + it('leaves ContentPart[] results untouched and enqueues the reminder', async () => { const h = createHarness(); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); @@ -426,10 +444,9 @@ describe('agentsMdReminder result shapes and edge cases', () => { ), ); - expect(Array.isArray(result.output)).toBe(true); - expect(outputText(result).startsWith('')).toBe(true); - expect(outputText(result)).toContain('part one'); - expect(outputText(result)).toContain(subAgentsMd); + expect(result.output).toEqual([{ type: 'text', text: 'part one' }]); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('does not mark an AGENTS.md known when the direct read failed', async () => { @@ -441,33 +458,29 @@ describe('agentsMdReminder result shapes and edge cases', () => { h, didCtx('Read', { path: agentsMdPath }, { result: { output: 'not found', isError: true } }), ); - expect(outputText(failed)).not.toContain(''); + expect(outputText(failed)).toBe('not found'); + expect(h.reminders).toHaveLength(0); await writeAgentsMd(subDir); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(after)).toContain(agentsMdPath); + expect(outputText(after)).toBe('original result'); + expect(reminderText(h)).toContain(agentsMdPath); }); }); -describe('agentsMdReminder toolDedupe interplay', () => { - it('delivers the reminder through a same-step duplicate resolved by toolDedupe', async () => { - const h = createHarness({ withDedupe: true }); - h.ix.get(IAgentToolDedupeService); +describe('agentsMdReminder duplicate calls', () => { + it('reminds exactly once for two same-step calls touching the same directory', async () => { + const h = createHarness(); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); const args = { path: join(workDir, 'packages', 'kap-server', 'index.ts') }; - await h.events.fireBeforeExecute(willCtx('call-1', 'Read', args)); - const did1 = didCtx('Read', args, { id: 'call-1' }); - await h.events.didExecuteSlot.run(did1); - expect(outputText(did1.result)).toContain(subAgentsMd); + const first = await fire(h, didCtx('Read', args, { id: 'call-1' })); + const second = await fire(h, didCtx('Read', args, { id: 'call-2' })); - const decision = await h.events.fireBeforeExecute(willCtx('call-2', 'Read', args)); - const did2 = didCtx('Read', args, { - id: 'call-2', - result: decision?.veto ?? { output: '' }, - }); - await h.events.didExecuteSlot.run(did2); - expect(outputText(did2.result)).toContain(subAgentsMd); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('leaves the vetoed placeholder untouched and reminds exactly once on the visible results', async () => { @@ -503,10 +516,10 @@ describe('agentsMdReminder toolDedupe interplay', () => { expect(results).toHaveLength(2); for (const item of results) { - const text = outputText(item.result); - expect(text).toContain('file contents'); - expect(text).toContain(subAgentsMd); + expect(outputText(item.result)).toBe('file contents'); } + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); const shown = h.telemetryEvents.filter((e) => e.event === 'agents_md_reminder_shown'); expect(shown).toHaveLength(1); }); @@ -523,19 +536,20 @@ describe('agentsMdReminder lazy seeding after a restore', () => { didCtx('Read', { path: join(workDir, 'packages', 'kap-server', 'index.ts') }), ); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); }); it('treats the brand-home AGENTS.md as injected after a restore', async () => { const h = createHarness(); - const brandAgentsMd = await writeAgentsMd(homeDir, 'brand instructions'); + await writeAgentsMd(homeDir, 'brand instructions'); const result = await fire(h, didCtx('Read', { path: join(homeDir, 'notes.txt') })); expect(outputText(result)).toBe('original result'); - expect(outputText(result)).not.toContain(brandAgentsMd); + expect(h.reminders).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); }); @@ -555,7 +569,8 @@ describe('agentsMdReminder persisted restore provenance', () => { await h.wire.hooks.onDidRestore.run({}); const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('recovers injected paths from a legacy restored prompt without path provenance', async () => { @@ -569,7 +584,8 @@ describe('agentsMdReminder persisted restore provenance', () => { await h.wire.hooks.onDidRestore.run({}); const result = await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); }); @@ -581,7 +597,8 @@ describe('agentsMdReminder Bash operand hygiene', () => { const result = await fire(h, didCtx('Bash', { command: 'ls -w 80 packages/kap-server' })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(eighty); }); @@ -595,7 +612,8 @@ describe('agentsMdReminder Bash operand hygiene', () => { didCtx('Bash', { command: "find -L packages/kap-server -name '*.ts'" }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); }); @@ -608,7 +626,8 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('still reminds when the triggering call ended in an error result', async () => { @@ -623,7 +642,8 @@ describe('agentsMdReminder probing boundaries', () => { }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('not found'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('marks an AGENTS.md known when it is written directly', async () => { @@ -633,10 +653,12 @@ describe('agentsMdReminder probing boundaries', () => { const agentsMdPath = normalize(join(subDir, 'AGENTS.md')); const written = await fire(h, didCtx('Write', { path: agentsMdPath, content: 'x' })); - expect(outputText(written)).not.toContain(''); + expect(outputText(written)).toBe('original result'); + expect(h.reminders).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(after)).not.toContain(agentsMdPath); + expect(outputText(after)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('reminds at most once for two parallel touches of the same directory', async () => { @@ -649,10 +671,9 @@ describe('agentsMdReminder probing boundaries', () => { fire(h, didCtx('Read', { path: join(subDir, 'b.ts') }, { id: 'call-b' })), ]); - const reminders = [first, second].filter((result) => - outputText(result).includes(''), - ); - expect(reminders).toHaveLength(1); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.reminders).toHaveLength(1); }); it('re-judges the project root at a nested repository', async () => { @@ -664,7 +685,8 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(nested, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(nestedAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -679,7 +701,8 @@ describe('agentsMdReminder probing boundaries', () => { try { const result = await fire(h, didCtx('Read', { path: join(leaf, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(leafAgentsMd); expect(text).not.toContain(outerAgentsMd); } finally { @@ -696,7 +719,8 @@ describe('agentsMdReminder probing boundaries', () => { try { const result = await fire(h, didCtx('Read', { path: join(workDir, 'link', 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(normalize(join(workDir, 'link', 'AGENTS.md'))); expect(text).not.toContain(targetAgentsMd); } finally { @@ -717,6 +741,7 @@ describe('agentsMdReminder round-2 hardening', () => { ); expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); @@ -729,9 +754,11 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Bash', { command: 'true' })); expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); const listed = await fire(h, didCtx('Bash', { command: 'ls packages' })); - expect(outputText(listed)).toContain(subAgentsMd); + expect(outputText(listed)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('ignores a whitespace-only AGENTS.md just like the init-time load', async () => { @@ -742,7 +769,8 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('keeps known-sets isolated between agents', async () => { @@ -754,8 +782,12 @@ describe('agentsMdReminder round-2 hardening', () => { const firstResult = await fire(first, didCtx('Read', { path: join(subDir, 'index.ts') })); const secondResult = await fire(second, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(firstResult)).toContain(subAgentsMd); - expect(outputText(secondResult)).toContain(subAgentsMd); + expect(outputText(firstResult)).toBe('original result'); + expect(outputText(secondResult)).toBe('original result'); + expect(first.reminders).toHaveLength(1); + expect(second.reminders).toHaveLength(1); + expect(reminderText(first)).toContain(subAgentsMd); + expect(reminderText(second)).toContain(subAgentsMd); }); it('releases the claim when attaching the reminder fails, so the next touch retries', async () => { @@ -772,26 +804,16 @@ describe('agentsMdReminder round-2 hardening', () => { const failed = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); expect(outputText(failed)).toBe('original result'); + expect(h.reminders).toHaveLength(0); shouldThrow = false; const retried = await fire(h, didCtx('Read', { path: join(subDir, 'b.ts') })); - expect(outputText(retried)).toContain(subAgentsMd); + expect(outputText(retried)).toBe('original result'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); - it('prepends the reminder so it survives head-only truncation', async () => { - const h = createHarness(); - const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); - - const result = await fire( - h, - didCtx('Read', { path: join(workDir, 'packages', 'kap-server', 'index.ts') }), - ); - - expect(outputText(result).startsWith('')).toBe(true); - expect(outputText(result)).toContain(subAgentsMd); - }); - - it('survives the real executor pipeline with oversized results', async () => { + it('leaves oversized results to the truncation pipeline and enqueues the reminder instead', async () => { const h = createHarness({ withRealExecutor: true }); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); @@ -827,8 +849,10 @@ describe('agentsMdReminder round-2 hardening', () => { expect(typeof output).toBe('string'); const text = output as string; expect(text).toContain('output_path:'); - expect(text.indexOf('')).toBeLessThan(2_000); - expect(text).toContain(subAgentsMd); + expect(text).not.toContain(''); + expect(text).not.toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('uses the resolved file access instead of reparsing the raw path', async () => { @@ -867,13 +891,15 @@ describe('agentsMdReminder round-2 hardening', () => { } expect(results).toHaveLength(1); - expect(outputText(results[0]!.result)).toContain(homeAgentsMd); + expect(outputText(results[0]!.result)).toBe('home file contents'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(homeAgentsMd); }); it('does not probe or remind when permission vetoes an access-bearing call', async () => { const h = createHarness({ withRealExecutor: true }); const subDir = join(workDir, 'packages', 'kap-server'); - const subAgentsMd = await writeAgentsMd(subDir); + await writeAgentsMd(subDir); const hostFs = h.ix.get(IHostFileSystem); const stat = vi.spyOn(hostFs, 'stat'); const readText = vi.spyOn(hostFs, 'readText'); @@ -915,7 +941,7 @@ describe('agentsMdReminder round-2 hardening', () => { expect(results).toHaveLength(1); expect(outputText(results[0]!.result)).toBe('permission denied'); - expect(outputText(results[0]!.result)).not.toContain(subAgentsMd); + expect(h.reminders).toHaveLength(0); expect(stat).not.toHaveBeenCalled(); expect(readText).not.toHaveBeenCalled(); expect( @@ -1005,7 +1031,7 @@ describe('agentsMdReminder cancellation outcomes', () => { const results = await pending; const queued = results.find((item) => item.toolCallId === 'call-queued-read'); expect(queued).toBeDefined(); - expect(outputText(queued!.result)).not.toContain(''); + expect(h.reminders).toHaveLength(0); expect( h.telemetryEvents.filter((event) => event.event === 'agents_md_reminder_shown'), ).toEqual([]); @@ -1027,7 +1053,9 @@ describe('agentsMdReminder cancellation outcomes', () => { )) { real.push(item); } - expect(outputText(real[0]!.result)).toContain(subAgentsMd); + expect(outputText(real[0]!.result)).toBe('read result'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); }); @@ -1041,7 +1069,8 @@ describe('agentsMdReminder Bash parse degradation', () => { didCtx('Bash', { command: "ls '", cwd: 'packages/kap-server' }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('skips entirely when an unparseable command has no explicit cwd', async () => { @@ -1051,6 +1080,7 @@ describe('agentsMdReminder Bash parse degradation', () => { const result = await fire(h, didCtx('Bash', { command: "ls '" })); expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); }); @@ -1094,7 +1124,8 @@ describe('agentsMdReminder Windows Bash paths', () => { const result = await fire(h, didCtx('Bash', args)); - expect(outputText(result)).toContain(agentsMdPath); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(agentsMdPath); } }); }); diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index ea376e9e9cb..7353102ff9f 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -14,7 +14,9 @@ import { createServices, type TestInstantiationService, } from '#/_base/di/test'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { + IAgentContextInjectorService, +} from '#/agent/contextInjector/contextInjector'; import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; @@ -26,6 +28,7 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemRemind import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; +import { registerLogServices } from '../../_base/log/stubs'; import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs'; import { runWillBeginStepHooks, @@ -72,7 +75,7 @@ describe('AgentContextInjectorService', () => { disposables = new DisposableStore(); loop = stubLoopWithHooks(); ix = createServices(disposables, { - base: [registerContextMemoryServices], + base: [registerContextMemoryServices, registerLogServices], strict: true, additionalServices: (reg) => { reg.defineInstance(IAgentLoopService, loop); @@ -89,8 +92,8 @@ describe('AgentContextInjectorService', () => { disposables.dispose(); }); - async function runInjectionStep(): Promise { - await runWillBeginStepHooks(loop); + async function runInjectionStep(firstStepOfTurn = false): Promise { + await runWillBeginStepHooks(loop, firstStepOfTurn); } function spliceContext( @@ -191,6 +194,41 @@ describe('AgentContextInjectorService', () => { expect(context.get()).toHaveLength(1); }); + it('reconciles only providers registered under the requested name while idle', async () => { + const seen: string[] = []; + injector(ix).register('target', () => { + seen.push('target'); + return 'target reminder'; + }); + injector(ix).register('other', () => { + seen.push('other'); + return 'other reminder'; + }); + + await injector(ix).reconcileWhenIdle('target'); + + expect(seen).toEqual(['target']); + expect(context.get()).toHaveLength(1); + expect(context.get()[0]?.origin).toEqual({ kind: 'injection', variant: 'target' }); + }); + + it('leaves reconciliation to the next step head when quiescence cannot be acquired', async () => { + let calls = 0; + injector(ix).register('target', () => { + calls++; + return 'target reminder'; + }); + loop.settled = async () => { + throw new Error('idle reconciliation must not wait for an active turn'); + }; + loop.tryAcquireQuiescence = () => undefined; + + await injector(ix).reconcileWhenIdle('target'); + + expect(calls).toBe(0); + expect(context.get()).toHaveLength(0); + }); + it('exposes all live injection positions alongside the newest one', async () => { const seen: Array = []; @@ -308,17 +346,17 @@ describe('AgentContextInjectorService', () => { ]); }); - it('re-arms per-turn providers when injectAfterCompaction runs', async () => { + it('re-arms per-turn providers at the first step after a compaction splice', async () => { const seen: boolean[] = []; injector(ix).register('per_turn_test', ({ isNewTurn }) => { seen.push(isNewTurn); return isNewTurn ? 'per-turn reminder' : undefined; }); - await runInjectionStep(); + await runInjectionStep(true); await runInjectionStep(); spliceContext(0, 1, [compactionSummary('Compacted summary.')]); - await injector(ix).injectAfterCompaction(); + await runInjectionStep(); expect(seen).toEqual([true, false, true]); expect(context.get().map((message) => message.origin)).toEqual([ @@ -326,4 +364,102 @@ describe('AgentContextInjectorService', () => { { kind: 'injection', variant: 'per_turn_test' }, ]); }); + + it('does not re-arm the new-turn flag for non-compaction splices', async () => { + const seen: boolean[] = []; + injector(ix).register('per_turn_test', ({ isNewTurn }) => { + seen.push(isNewTurn); + return undefined; + }); + + await runInjectionStep(true); + spliceContext(0, 0, [userMessage('between steps')]); + await runInjectionStep(); + + expect(seen).toEqual([true, false]); + }); + + it('re-reconciles within the same step when compaction lands inside the step hook chain', async () => { + const seen: boolean[] = []; + injector(ix).register('per_turn_test', ({ isNewTurn }) => { + seen.push(isNewTurn); + return isNewTurn ? 'per-turn reminder' : undefined; + }); + loop.hooks.onWillBeginStep.register('test-compaction', async (_ctx, next) => { + spliceContext(0, 1, [compactionSummary('Compacted summary.')]); + await next(); + }); + + await runInjectionStep(true); + + expect(seen).toEqual([true, true]); + expect(context.get().map((message) => message.origin)).toEqual([ + { kind: 'compaction_summary' }, + { kind: 'injection', variant: 'per_turn_test' }, + ]); + }); + + it('appends tagged raw messages verbatim with the injection origin stamped', async () => { + injector(ix).register('schema_test', () => ({ + message: { + role: 'system', + content: [], + tools: [{ name: 'TestTool', description: 'test tool', parameters: { type: 'object' } }], + }, + })); + + await runInjectionStep(); + + const message = context.get().at(-1); + expect(message?.role).toBe('system'); + expect(message?.tools).toEqual([ + { name: 'TestTool', description: 'test tool', parameters: { type: 'object' } }, + ]); + expect(message?.origin).toEqual({ kind: 'injection', variant: 'schema_test' }); + }); + + it('stamps the disclosure on tagged raw messages returned through the result wrapper', async () => { + injector(ix).register('schema_test', () => ({ + content: { message: { role: 'user', content: [{ type: 'text', text: 'raw' }] } }, + disclosure: { kind: 'test_receipt', id: 'r1' }, + })); + + await runInjectionStep(); + + expect(context.get().at(-1)?.origin).toEqual({ + kind: 'injection', + variant: 'schema_test', + disclosure: { kind: 'test_receipt', id: 'r1' }, + }); + }); + + it('skips tagged raw messages with neither content nor tools', async () => { + injector(ix).register('empty_raw_test', () => ({ message: { role: 'system', content: [] } })); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(0); + }); + + it('skips a throwing step provider and still runs the rest', async () => { + injector(ix).register('step_throwing', () => { + throw new Error('boom'); + }); + injector(ix).register('step_surviving', () => 'surviving reminder'); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('surviving reminder'); + }); + + it('skips a rejecting step provider and still runs the rest', async () => { + injector(ix).register('step_rejecting', () => Promise.reject(new Error('boom'))); + injector(ix).register('step_surviving', () => 'surviving reminder'); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('surviving reminder'); + }); }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 0b3b2ca201e..7165aebbac0 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -672,7 +672,7 @@ describe('Agent context', () => { ]); }); - it('removes a pre-anchor image compression reminder when undoing its prompt', async () => { + it('removes the prompt-owned image compression reminder when undoing its prompt', async () => { profile.update({ activeToolNames: [] }); const caption = buildImageCompressionCaption({ original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' }, @@ -686,8 +686,14 @@ describe('Agent context', () => { await ctx.untilTurnEnd(); expect(context.get()).toMatchObject([ - { origin: { kind: 'injection', variant: 'image_compression' } }, - { origin: { kind: 'user' } }, + { + origin: { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: expect.any(String), + }, + }, + { origin: { kind: 'user' }, id: expect.any(String) }, { role: 'assistant' }, ]); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index ba6aef5624c..ea8155fe292 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -53,6 +53,7 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { publishSplice(eventBus, { start, deleteCount: 0, messages: [...inserted] }); }, appendLoopEvent: () => {}, + publishTrailingRemoval: () => false, clear: () => { const deleteCount = messages.length; if (deleteCount === 0) return; @@ -106,6 +107,9 @@ class StubContextMemoryService implements IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void { this.impl.appendLoopEvent(event); } + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { + return this.impl.publishTrailingRemoval(previous); + } undo(count: number): UndoCut { return this.impl.undo(count); } 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 f32a9c1959c..2aab73c8c7a 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -303,7 +303,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 3_299, + tokens_before: 3_294, tokens_after: expect.any(Number), duration_ms: expect.any(Number), compacted_count: 6, @@ -318,6 +318,44 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('holds the loop quiescence lease for the full manual compaction', async () => { + const ctx = testAgent(); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); + let release!: () => void; + const canCompact = new Promise((resolve) => { + release = resolve; + }); + let started!: () => void; + const compactionStarted = new Promise((resolve) => { + started = resolve; + }); + const hook = ctx.get(IAgentFullCompactionService).hooks.onWillCompact.register( + 'test-quiescence', + async (_task, next) => { + started(); + await canCompact; + await next(); + }, + ); + ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' }); + + expect(ctx.get(IAgentFullCompactionService).begin({ source: 'manual' })).toBe(true); + await compactionStarted; + expect(ctx.get(IAgentLoopService).tryAcquireQuiescence()).toBeUndefined(); + + release(); + await ctx.get(IAgentFullCompactionService).compacting?.promise; + const lease = ctx.get(IAgentLoopService).tryAcquireQuiescence(); + expect(lease).toBeDefined(); + lease?.dispose(); + hook.dispose(); + }); + it('refreshes the active profile system prompt after compaction without resetting active tools', async () => { const homeDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-home-')); const workDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-work-')); @@ -544,7 +582,7 @@ describe('FullCompaction', () => { session_id: 'test-session', cwd: dir, trigger: 'auto', - token_count: 3_299, + token_count: 3_294, }); expect(post).toMatchObject({ hook_event_name: 'PostCompact', @@ -630,7 +668,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'manual', - tokens_before: 14_365, + tokens_before: 14_360, retry_count: 1, trace_id: 'trace-compact-1', }), @@ -1013,7 +1051,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 14_365, + tokens_before: 14_360, duration_ms: expect.any(Number), round: 1, retry_count: 0, @@ -1238,7 +1276,7 @@ describe('FullCompaction', () => { event: 'compaction_failed', properties: expect.objectContaining({ source: 'manual', - tokens_before: 14_365, + tokens_before: 14_360, duration_ms: expect.any(Number), retry_count: 4, error_type: 'APIConnectionError', @@ -1460,6 +1498,7 @@ describe('FullCompaction', () => { ctx.get(IAgentFullCompactionService).begin({ source: 'auto', instruction: undefined }); await completed; + await ctx.wire.flush(); const events = ctx.newEvents(); const compactedPrefixSizes = ctx.llmCalls.map((call) => @@ -1613,12 +1652,12 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - tokens_before: 3_306, - // 3260 estimated request-overhead tokens (system prompt + tools) + + tokens_before: 3_301, + // 3255 estimated request-overhead tokens (system prompt + tools) + // 9 measured summary output tokens (scripted compaction exchange) + // 21 estimated tokens for the kept user messages — the summary // component is the REAL provider count, not a text estimate. - tokens_after: 3_290, + tokens_after: 3_285, compacted_count: 7, retry_count: 0, }), @@ -3306,11 +3345,13 @@ describe('goal reminder re-injection after full compaction', () => { await ctx.untilTurnEnd(); expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(2); - expect(goalReminderCount(ctx.llmCalls[0]!.history)).toBe(0); + // The goal reminder now enters at the first step head (before the + // overflow triggers compaction), so the summarizer request sees it too. + expect(goalReminderCount(ctx.llmCalls[0]!.history)).toBe(1); expect(goalReminderCount(ctx.llmCalls[1]!.history)).toBe(1); }); - it('counts the re-injected goal reminder into the post-compaction token floor', async () => { + it('re-injects the goal reminder at the first step after compaction', async () => { const records: TelemetryRecord[] = []; const ctx = testAgent({ telemetry: recordingTelemetry(records) }); ctx.configure({ @@ -3326,12 +3367,14 @@ describe('goal reminder re-injection after full compaction', () => { await ctx.rpc.beginCompaction({}); await completed; + // Re-injection is deferred to the next step head, so nothing is appended + // at compaction time and the token floor is exactly the compaction result. const reminderMessages = ctx.context .get() .filter( (message) => message.origin?.kind === 'injection' && message.origin.variant === 'goal', ); - expect(reminderMessages).toHaveLength(1); + expect(reminderMessages).toHaveLength(0); const tokensAfter = records.find((record) => record.event === 'compaction_finished') ?.properties?.['tokens_after']; @@ -3342,12 +3385,12 @@ describe('goal reminder re-injection after full compaction', () => { } ).lastCompactedTokenCount; expect(floor).toBe(ctx.get(IAgentTokenCountingService).get().size); - expect(floor!).toBeGreaterThan(tokensAfter as number); + expect(floor).toBe(tokensAfter); ctx.mockNextResponse({ type: 'text', text: 'Reply after compaction.' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'next prompt' }] }); await ctx.untilTurnEnd(); - expect(goalReminderCount(ctx.llmCalls.at(-1)!.history)).toBe(2); + expect(goalReminderCount(ctx.llmCalls.at(-1)!.history)).toBe(1); }); it('replays a deferred prompt whose first request carries the re-injected goal reminder', async () => { diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 07232ac97ac..b7ca8d0ec53 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -27,7 +27,7 @@ import { } from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode, PermissionPolicyResult } from '#/agent/permissionPolicy/types'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; @@ -212,11 +212,13 @@ async function runGoalStep(loopService: StubLoop, turn: Turn): Promise const step = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }; const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, @@ -498,7 +500,10 @@ describe('AgentGoalService', () => { expect(removed.status).toBe('active'); expect(goals.getGoal()).toEqual({ goal: null }); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_cancelled' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'goal_cancelled', + }); expect(JSON.stringify(reminder?.content)).toContain('Ignore earlier active-goal reminders'); await expect(goals.cancelGoal()).rejects.toMatchObject({ code: ErrorCodes.GOAL_NOT_FOUND }); }); @@ -899,6 +904,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -924,6 +930,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -969,6 +976,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); const toolCall: ToolCall = { @@ -1000,6 +1008,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); const toolCall: ToolCall = { @@ -1027,6 +1036,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: oldTurn.id, step: 1, + firstStepOfTurn: true, signal: oldTurn.signal, }); recordStepUsage(usageService, goals, oldTurn, { ...zeroUsage, output: 5 }); @@ -1052,6 +1062,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onDidFinishStep.run({ turnId: oldTurn.id, step: 1, + firstStepOfTurn: true, signal: oldTurn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1138,6 +1149,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: continuationTurn.id, step: 1, + firstStepOfTurn: true, signal: continuationTurn.signal, }); recordStepUsage(usageService, goals, continuationTurn, { ...zeroUsage, output: 7 }); @@ -1301,6 +1313,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -1312,6 +1325,7 @@ describe('AgentGoalService core workflow hooks', () => { const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1351,6 +1365,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: continuation.id, step: 1, + firstStepOfTurn: true, signal: continuation.signal, }); @@ -1371,6 +1386,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); await goals.markBlocked({}, 'model'); @@ -1379,6 +1395,7 @@ describe('AgentGoalService core workflow hooks', () => { const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1502,11 +1519,13 @@ describe('AgentGoalService core workflow hooks', () => { const step = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }; const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, @@ -1528,6 +1547,7 @@ describe('AgentGoalService core workflow hooks', () => { const secondAfterStep: AfterStepContext = { turnId: turn.id, step: 2, + firstStepOfTurn: false, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, @@ -1998,7 +2018,7 @@ describe('AgentGoalService mid-turn budget stop', () => { const toolResultIndex = history.findIndex((message) => message.role === 'tool'); const reminderIndex = history.findIndex( (message) => - message.origin?.kind === 'system_trigger' && message.origin.name === 'goal_budget_stop', + message.origin?.kind === 'injection' && message.origin.variant === 'goal_budget_stop', ); expect(toolResultIndex).toBeGreaterThanOrEqual(0); expect(reminderIndex).toBeGreaterThan(toolResultIndex); @@ -2301,13 +2321,37 @@ describe('AgentGoalService fork boundaries', () => { expect(goals.getGoal().goal).toBeNull(); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'goal_fork_cleared', + }); const text = JSON.stringify(reminder?.content); expect(text).toContain('This fork does not have a current goal.'); expect(text).toContain('Ignore earlier active-goal reminders from the source session.'); expect(text).toContain('Handle requests normally unless the user starts a new goal.'); }); + it('does not re-deliver a fork-cleared reminder recorded with the legacy system_trigger origin', async () => { + await restoreGoalRecords(ctx, goals, [ + { type: 'goal.create', goalId: 'source-goal', objective: 'source work' }, + { type: 'forked' }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [ + { type: 'text', text: '\nlegacy fork cleared\n' }, + ], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'goal_fork_cleared' }, + }, + }, + ]); + + expect(context.get()).toHaveLength(1); + expect(context.get()[0]?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); + }); + it('does not append a fork-cleared reminder when the fork had no goal', async () => { await restoreGoalRecords(ctx, goals, [{ type: 'forked' }]); diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index b23e0da322e..eb877db2566 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -71,10 +71,10 @@ function createInjectorStub(): IAgentContextInjectorService { } as unknown as IAgentContextInjectorService; } -function createRemindersStub(): IAgentSystemReminderService { +function createSystemReminderStub(): IAgentSystemReminderService { return { _serviceBrand: undefined, - appendSystemReminder: () => undefined, + appendSystemReminder: () => ({}), } as unknown as IAgentSystemReminderService; } @@ -124,7 +124,7 @@ function buildHost(key: string): { } as unknown as IAgentUsageService); ix.stub(IAgentContextMemoryService, createContextStub()); ix.stub(IAgentContextInjectorService, createInjectorStub()); - ix.stub(IAgentSystemReminderService, createRemindersStub()); + ix.stub(IAgentSystemReminderService, createSystemReminderStub()); ix.stub(ITelemetryService, createTelemetryStub()); ix.stub(IAgentToolExecutorService, createToolExecutorStub()); ix.stub(IConfigService, createConfigStub()); diff --git a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts index d5379b463c7..f88ad343f80 100644 --- a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts @@ -6,7 +6,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { IAgentGoalService } from '#/agent/goal/goal'; import { type AgentGoalService } from '#/agent/goal/goalService'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { InMemoryWireRecordPersistence, agentService, @@ -17,10 +17,15 @@ import { import { stubAgentSwarm } from '../stubs'; type GoalServiceTestManager = IAgentGoalService & AgentGoalService; -type InjectableContextInjector = IAgentContextInjectorService & { inject(): Promise }; +type InjectableContextInjector = IAgentContextInjectorService & { + inject(isNewTurn: boolean): Promise; +}; -async function injectDynamic(injector: InjectableContextInjector): Promise { - await injector.inject(); +async function injectDynamic( + injector: InjectableContextInjector, + isNewTurn: boolean, +): Promise { + await injector.inject(isNewTurn); } async function registerLookupTool( @@ -76,7 +81,7 @@ describe('GoalInjection content', () => { configure: (goals: GoalServiceTestManager) => Promise, ): Promise { await configure(goals); - await injectDynamic(injector); + await injectDynamic(injector, true); return lastGoalReminder(context); } @@ -292,7 +297,7 @@ describe('GoalInjection integration', () => { it('main-agent dynamic injection writes a context.append_message with origin.variant goal', async () => { await goals.createGoal({ objective: 'Ship feature X' }); - await injectDynamic(injector); + await injectDynamic(injector, true); const goalRecords = await flushedGoalReminderRecords(ctx, persistence); expect(goalRecords).toHaveLength(1); @@ -303,8 +308,8 @@ describe('GoalInjection integration', () => { it('dynamic injection writes at most once for one turn boundary', async () => { await goals.createGoal({ objective: 'Ship feature X' }); - await injectDynamic(injector); - await injectDynamic(injector); + await injectDynamic(injector, true); + await injectDynamic(injector, false); await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(1); }); @@ -363,7 +368,7 @@ describe('GoalInjection integration', () => { }); it('writes no goal record when there is no active goal', async () => { - await injectDynamic(injector); + await injectDynamic(injector, true); await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(0); }); diff --git a/packages/agent-core-v2/test/agent/goal/stubs.ts b/packages/agent-core-v2/test/agent/goal/stubs.ts index 4dd1e67808b..b4f463805fd 100644 --- a/packages/agent-core-v2/test/agent/goal/stubs.ts +++ b/packages/agent-core-v2/test/agent/goal/stubs.ts @@ -2,7 +2,7 @@ * Shared stubs for goal tests. */ -import type { IAgentSwarmService } from '#/agent/swarm/swarm'; +import type { IAgentSwarmService } from '#/features/swarm/agent/swarm'; export function stubAgentSwarm(): IAgentSwarmService { return { diff --git a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts index 27984824f8a..d25250996ba 100644 --- a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts +++ b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts @@ -21,7 +21,7 @@ import { UpdateGoalToolInputSchema } from '#/agent/tools/goal/update-goal/update import { UpdateGoalTool } from '#/agent/tools/goal/update-goal/updateGoalTool'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; import { IAgentToolExecutorService, type ToolExecutionResult, @@ -402,6 +402,7 @@ describe('goal tools', () => { await loopService.hooks.onWillBeginStep.run({ turnId, step: 1, + firstStepOfTurn: true, signal: abortController.signal, }); } 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 6ed1e1c4f6c..888ae1a8b93 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -64,6 +64,7 @@ describe('Agent loop', () => { [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "