diff --git a/.changeset/compact-threshold-token-budget.md b/.changeset/compact-threshold-token-budget.md new file mode 100644 index 00000000000..5a1b68d1ec9 --- /dev/null +++ b/.changeset/compact-threshold-token-budget.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add `/compact-threshold-k [|off]` slash command: set a session-scoped absolute compaction trigger in thousands of tokens (e.g. `/compact-threshold-k 120` triggers at 120 000 tokens, regardless of the active model's context size). Mirrors the existing `/compact-threshold` (ratio) command but takes an absolute token cap. The SDK exposes `Session.setCompactionTokenBudget(tokens?)` and the session status payload now includes `compactionTokenBudget` / `compactionTokenBudgetOverridden`. diff --git a/.changeset/drop-subagent-summary-bounce.md b/.changeset/drop-subagent-summary-bounce.md new file mode 100644 index 00000000000..bc9c2f59cf5 --- /dev/null +++ b/.changeset/drop-subagent-summary-bounce.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Subagent final messages are no longer bounced back for expansion when they are under 200 characters. diff --git a/.changeset/file-history-turn-snapshots.md b/.changeset/file-history-turn-snapshots.md deleted file mode 100644 index b5a891b096b..00000000000 --- a/.changeset/file-history-turn-snapshots.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Add experimental turn-level file snapshots (enable with KIMI_CODE_EXPERIMENTAL_FILE_HISTORY=1): each turn records the files it edits — their content from before the first edit and after the turn ends — keeping the last five editing turns for the thirty most recently active sessions of each workspace. diff --git a/.changeset/permission-mode-file-warning.md b/.changeset/permission-mode-file-warning.md new file mode 100644 index 00000000000..7dc75fdd79f --- /dev/null +++ b/.changeset/permission-mode-file-warning.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show a warning after switching to Ask When Needed or Never Ask mode. diff --git a/.changeset/resume-subagent-after-restart.md b/.changeset/resume-subagent-after-restart.md new file mode 100644 index 00000000000..89e84dca36f --- /dev/null +++ b/.changeset/resume-subagent-after-restart.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Resuming a subagent by its agent id now works after the session is reopened in a new process; the resumed subagent follows the current permission mode and is matched by its own profile in permission rules. diff --git a/.changeset/sdk-suggest-files.md b/.changeset/sdk-suggest-files.md deleted file mode 100644 index ec6d260b27e..00000000000 --- a/.changeset/sdk-suggest-files.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code-sdk": minor ---- - -Add `suggestFiles(workDir, { query, limit })` to `KimiHarness`: session-less fuzzy file suggestions from the agent-core-v2 workspace fs service, `undefined` on the v1 engine. diff --git a/.changeset/tower-mode-fixes.md b/.changeset/tower-mode-fixes.md new file mode 100644 index 00000000000..62e5e5c1beb --- /dev/null +++ b/.changeset/tower-mode-fixes.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): fix tower mode never starting when enabled through `[experimental] tower = true` in `config.toml` instead of the environment variable. When tower mode cannot be enabled, the error now names the actual blocker — the disabled experiment, a required restart, or the owning session. When another live session owns the workspace tower, the message also names the owning session's title alongside its id. /tower now also works in a directory that is not a git repository — it runs git init and commits what is there (an empty initial commit for empty directories). diff --git a/.changeset/vscode-at-engine-suggest.md b/.changeset/vscode-at-engine-suggest.md deleted file mode 100644 index f2ccb968093..00000000000 --- a/.changeset/vscode-at-engine-suggest.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kimi-code": patch ---- - -Highlight matched characters in @ file suggestions and allow folders to be inserted as mentions. diff --git a/.changeset/vscode-at-menu-entries.md b/.changeset/vscode-at-menu-entries.md deleted file mode 100644 index 24b57657ec5..00000000000 --- a/.changeset/vscode-at-menu-entries.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kimi-code": patch ---- - -Show the image/video picker entry in the @ menu only before a search query is typed, and remove the Browse folders mode. diff --git a/.changeset/vscode-at-scroll-jitter.md b/.changeset/vscode-at-scroll-jitter.md deleted file mode 100644 index fecde932570..00000000000 --- a/.changeset/vscode-at-scroll-jitter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kimi-code": patch ---- - -Fix the @ and / suggestion lists jittering when the mouse rests at the scroll edge. diff --git a/AGENTS.md b/AGENTS.md index 42eb53caa3e..ff68d775c1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,14 +71,14 @@ zread generate --stdio - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session transcript chat, per-scope Service panels, and the DI unit inspection view. See `apps/kimi-inspect/AGENTS.md`. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. See `packages/agent-core/AGENTS.md`. -- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. +- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. -- `packages/transcript`: the isomorphic transcript rendering data layer — L1 agent-granular store, L2 idempotent operations, L3 `off/turn/block/delta` subscription granularity, L4 framework-free view registry, plus turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports); the sole owner of the transcript contract types (`src/contract/`) and the op-batch sequencing contract. See `packages/transcript/AGENTS.md`. -- `packages/kap-server`: the Kimi Code server, backed by `@moonshot-ai/agent-core-v2`; exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`), plus the `/api/v1/debug/*` reflection RPC surface (`--debug-endpoints`, loopback bind + bearer auth). See `packages/kap-server/AGENTS.md`. +- `packages/transcript`: the isomorphic transcript rendering data layer — L1 agent-granular store, L2 idempotent operations, L3 `off/turn/block/delta` subscription granularity, L4 framework-free view registry, plus turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports); the sole owner of the transcript contract types (`src/contract/`) and the op-batch sequencing contract. +- `packages/kap-server`: the Kimi Code server, backed by `@moonshot-ai/agent-core-v2`; exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`), plus the `/api/v1/debug/*` reflection RPC surface (`--debug-endpoints`, loopback bind + bearer auth). - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 (`global.*` / `session(id).*` / `agent(id).*`, zod-validated); transport via subpath entry (`@moonshot-ai/klient/ipc|memory`, both return the same `Klient`); also hosts the e2e suites. See `packages/klient/AGENTS.md`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm); `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget and returns a discriminated `ParseResult` — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; see the package README's "Known differences" section. - `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock, a larger-than-RAM full-text layer, and persistent index generations. See `packages/minidb/AGENTS.md`. @@ -117,7 +117,7 @@ zread generate --stdio - Gate a not-yet-public feature behind an experimental flag. Flags are env-driven and default off: `KIMI_CODE_EXPERIMENTAL_` toggles one, `KIMI_CODE_EXPERIMENTAL_FLAG` enables all. Precedence is per-flag env > `[experimental]` config > master env > the flag's `default`. Release by flipping the entry's `default` to `true`. - `packages/agent-core` (v1): add the flag to the central registry at `packages/agent-core/src/flags/registry.ts`, then check it with `flags.enabled('my-feature')`. - - `packages/agent-core-v2` and kap-server modules: there is no central catalog — declare the flag in the owning domain via `registerFlagDefinition` at import time (see `packages/agent-core-v2/docs/flag.md`), then check it with `IFlagService.enabled(id)`. Current search-index-separation flags: `persistence_minidb_readmodel` (session read model, default on) and `search_worker` (global search worker host, default on). + - `packages/agent-core-v2` and kap-server modules: there is no central catalog — declare the flag in the owning domain via `registerFlagDefinition` at import time, then check it with `IFlagService.enabled(id)`. Current search-index-separation flags: `persistence_minidb_readmodel` (session read model, default on) and `search_worker` (global search worker host, default on). ## Where to Update Instructions diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 60f3ce39b6e..1075d86cda7 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kimi-code +## 0.40.1 + +### Patch Changes + +- [#3469](https://github.com/MoonshotAI/kimi-code/pull/3469) [`979baad`](https://github.com/MoonshotAI/kimi-code/commit/979baad8597aa1760917752b3663f1eb4e40eeb0) Thanks [@sailist](https://github.com/sailist)! - Fix the condition for showing the kimi-cli migration prompt. + ## 0.40.0 ### Minor Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index da3b067a3d1..65d0b2a1bee 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.40.0", + "version": "0.40.1", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index bbf5eedc2b7..c22f0a68a2c 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -209,7 +209,7 @@ export async function runV2Print( // model is reconciled via setContext once resolved. telemetryService = app.accessor.get(ITelemetryService); if (telemetryEnabled) { - telemetryService.setAppender( + telemetryService.addAppender( createCloudAppender(app.accessor, { deviceId, appName: CLI_USER_AGENT_PRODUCT, @@ -223,7 +223,7 @@ export async function runV2Print( const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); restorePermission = resolved.restorePermission; - telemetryService.setContext({ sessionId: resolved.session.id, model: resolved.telemetryModel }); + telemetryService.setContext({ session_id: resolved.session.id, model: resolved.telemetryModel }); if (firstLaunch) { telemetryService.track2('first_launch'); } @@ -253,7 +253,7 @@ export async function runV2Print( } writeResumeHint(resolved.session.id, outputFormat, stdout, stderr); - telemetryService.withContext({ sessionId: resolved.session.id }).track2('exit', { + telemetryService.withContext({ session_id: resolved.session.id }).track2('exit', { duration_ms: Date.now() - startedAt, }); } finally { diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts index a56a331858d..5ad58fc1747 100644 --- a/apps/kimi-code/src/migration/detect-pending.ts +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -7,7 +7,6 @@ import { existsSync } from 'node:fs'; import { detectMigration, - countImportedSessionsNeedingRepair, shouldSuppressMigration, type MigrationPlan, } from '@moonshot-ai/migration-legacy'; @@ -35,16 +34,8 @@ export async function detectPendingMigration( ): Promise { const { sourceHome, targetHome } = input; if (!existsSync(sourceHome)) return null; - // Imported sessions an older migrator left without turn-structure records - // are unfinished migration work the completion marker must not hide — a - // repair need lifts the suppression. The scan is cheap (one state.json plus - // a wire-head read per imported session) and failure-tolerant. - const sessionsNeedingRepair = await countImportedSessionsNeedingRepair(targetHome).catch( - () => 0, - ); if ( input.ignoreMarker !== true && - sessionsNeedingRepair === 0 && shouldSuppressMigration({ sourceHome, targetHome }) ) { return null; @@ -73,9 +64,8 @@ export async function detectPendingMigration( !plan.hasUserHistory && !plan.hasSkills && !plan.hasPlans && - sessionsNeedingRepair === 0 && (plan.sessionScanFailures?.length ?? 0) === 0; if (nothingToMigrate) return null; - return { ...plan, sessionsNeedingRepair }; + return plan; } diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index 6477185f375..1a26d85ab74 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -304,11 +304,6 @@ export class MigrationScreenComponent extends Container implements Focusable { chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsMigrated} sessions migrated`), ); } - if (sum.sessions.sessionsRepaired > 0) { - lines.push( - chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsRepaired} sessions repaired`), - ); - } if (sum.plans.copied > 0) { lines.push(chalk.hex(colors.success)(` ✓ ${sum.plans.copied} plan files copied`)); } @@ -325,7 +320,6 @@ export class MigrationScreenComponent extends Container implements Focusable { } if ( sum.sessions.sessionsMigrated === 0 && - sum.sessions.sessionsRepaired === 0 && sum.plans.copied === 0 && migratedKinds.length === 0 ) { diff --git a/apps/kimi-code/src/migration/run-headless.ts b/apps/kimi-code/src/migration/run-headless.ts index 396045d5a3b..491d560fd21 100644 --- a/apps/kimi-code/src/migration/run-headless.ts +++ b/apps/kimi-code/src/migration/run-headless.ts @@ -174,7 +174,7 @@ function logReport( if (scope.sessions) { log( `sessions: scanned=${s.bucketsScanned} attempted=${s.sessionsAttempted} migrated=${s.sessionsMigrated}` + - ` already-migrated=${s.sessionsAlreadyMigrated} repaired=${s.sessionsRepaired} skipped-empty=${s.sessionsSkippedEmpty}` + + ` already-migrated=${s.sessionsAlreadyMigrated} skipped-empty=${s.sessionsSkippedEmpty}` + ` skipped-malformed=${s.sessionsSkippedMalformed} skipped-placeholder=${s.sessionsSkippedPlaceholder}` + ` failed=${s.sessionsFailed.length} conflicts=${s.sessionsConflicts.length}` + (s.bucketsSkippedNonlocalKaos > 0 ? ` buckets-skipped-nonlocal-kaos=${s.bucketsSkippedNonlocalKaos}` : '') + diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index ac584eff034..dfd2f961a42 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -26,7 +26,7 @@ import { ConfirmDialogComponent } from '../components/dialogs/confirm-dialog'; import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; import type { ThemeName } from '#/tui/theme'; import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; -import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { NO_ACTIVE_SESSION_MESSAGE, UNCONFIRMED_FILE_CHANGES_WARNING } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; import { thinkingEffortToConfig } from '../utils/thinking-config'; @@ -149,6 +149,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P await session?.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); host.showNotice('Ask When Needed mode: ON', 'Routine edits and commands run automatically; risky actions, questions, and plans still ask.'); + host.showStatus(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); return; } @@ -172,6 +173,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P await session?.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); host.showNotice('Ask When Needed mode: ON', 'Routine edits and commands run automatically; risky actions, questions, and plans still ask.'); + host.showStatus(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); } } @@ -195,6 +197,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P await session?.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); host.showNotice('Never Ask mode: ON', 'Never interrupts you; everything runs and is decided automatically.'); + host.showStatus(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); return; } @@ -218,6 +221,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P await session?.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); host.showNotice('Never Ask mode: ON', 'Never interrupts you; everything runs and is decided automatically.'); + host.showStatus(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); } } @@ -1764,6 +1768,9 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod host.setAppState({ permissionMode: mode }); host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}`); + if (mode !== 'manual') { + host.showStatus(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); + } } export function showSettingsSelector(host: SlashCommandHost): void { diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index 169234b4552..c6f23c1f555 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -15,7 +15,7 @@ import { GoalStatusMessageComponent, UpcomingGoalAddedMessageComponent, } from '../components/messages/goal-panel'; -import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import { LLM_NOT_SET_MESSAGE, UNCONFIRMED_FILE_CHANGES_WARNING } from '../constant/kimi-tui'; import { appendGoalQueueItem, moveGoalQueueItem, @@ -25,6 +25,7 @@ import { type GoalQueueSnapshot, } from '../goal-queue-store'; import { formatErrorMessage } from '../utils/event-payload'; +import { PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; import { canRestoreSubmittedInput } from './resolve'; import type { SlashCommandHost } from './dispatch'; @@ -39,6 +40,7 @@ type GoalCommandHost = Pick< | 'requireSession' | 'setAppState' | 'showError' + | 'showNotice' | 'showStatus' | 'track' | 'mountEditorReplacement' @@ -443,6 +445,14 @@ async function startGoalWithPermission( // previous mode so the session is not left more permissive than before. if (!started && switched) { await setPermissionForGoal(host, previousMode); + return; + } + // Announce the switch only once the goal actually starts: shown earlier, a + // failed creation would leave a stale permissive-mode notice in the + // transcript even though the rollback above restored the previous mode. + if (switched) { + host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[choice]}`); + host.showStatus(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); } } diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 540aa58604e..bed78566db5 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -8,8 +8,9 @@ import { SwarmModeMarkerComponent, type SwarmModeMarkerState, } from '../components/messages/swarm-markers'; -import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE, UNCONFIRMED_FILE_CHANGES_WARNING } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; import type { SlashCommandHost } from './dispatch'; export async function handleSwarmCommand(host: SlashCommandHost, args: string): Promise { @@ -85,6 +86,8 @@ async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMod return false; } host.setAppState({ permissionMode: mode }); + host.showNotice(`Permission mode: ${PERMISSION_MODE_DISPLAY_NAMES[mode]}`); + host.showStatus(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); return true; } diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts index f88c1861ba1..5a0523f58f0 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -57,7 +57,7 @@ export class NoticeMessageComponent extends Container { this.titleText = new Text(` ${currentTheme.fg('textStrong', title)}`, 0, 0); this.addChild(this.titleText); if (detail !== undefined && detail.length > 0) { - this.detailText = new Text(` ${currentTheme.fg('textDim', detail)}`, 0, 0); + this.detailText = new Text(this.renderDetail(detail), 0, 0); this.addChild(this.detailText); } } @@ -65,8 +65,15 @@ export class NoticeMessageComponent extends Container { override invalidate(): void { this.titleText.setText(` ${currentTheme.fg('textStrong', this.title)}`); if (this.detailText !== undefined && this.detail !== undefined) { - this.detailText.setText(` ${currentTheme.fg('textDim', this.detail)}`); + this.detailText.setText(this.renderDetail(this.detail)); } super.invalidate(); } + + // Indent every line, not just the first. The `detail` may be multi-line; + // prefixing the whole string once would only indent the first line and leave + // the rest at column 0 (same handling as StatusMessageComponent). + private renderDetail(detail: string): string { + return currentTheme.fg('textDim', detail).split('\n').map((line) => ` ${line}`).join('\n'); + } } diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index 45c77d177b8..9d9f0ba9479 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -4,6 +4,8 @@ export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send /login to login.'; +export const UNCONFIRMED_FILE_CHANGES_WARNING = + 'In this mode, Kimi Code can modify or delete files without your confirmation'; export const CTRL_D_HINT = 'Press Ctrl+D again to exit'; export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 796955c8e6b..173046b2450 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -246,7 +246,7 @@ function makeFakeHarness() { ITelemetryService, (() => { const svc = { - setAppender: vi.fn(), + addAppender: vi.fn(() => ({ dispose: vi.fn() })), setContext: vi.fn(), track: vi.fn(), track2: vi.fn(), diff --git a/apps/kimi-code/test/migration/detect-pending.test.ts b/apps/kimi-code/test/migration/detect-pending.test.ts index bafa91a27cc..ff02e4ebffa 100644 --- a/apps/kimi-code/test/migration/detect-pending.test.ts +++ b/apps/kimi-code/test/migration/detect-pending.test.ts @@ -139,49 +139,4 @@ describe('detectPendingMigration', () => { await rm(skillsHome, { recursive: true, force: true }); } }); - - async function seedImportedSession(wireSecondLine: string, importFormatVersion?: number): Promise { - const dir = join(tgt, 'sessions', 'wd_test', 'ses_old-import', 'agents', 'main'); - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, 'wire.jsonl'), - '{"type":"metadata","protocol_version":"1.0","created_at":1}\n' + wireSecondLine + '\n', - ); - await writeFile( - join(tgt, 'sessions', 'wd_test', 'ses_old-import', 'state.json'), - JSON.stringify({ - custom: { imported_from_kimi_cli: true, import_format_version: importFormatVersion }, - }), - ); - } - - it('lifts marker suppression when an imported session still lacks turn structure', async () => { - await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); - await writeFile( - join(src, '.migrated-to-kimi-code'), - JSON.stringify({ version: 1, target_path: tgt }), - 'utf-8', - ); - await seedImportedSession( - '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"x"}],"toolCalls":[]}}', - ); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).not.toBeNull(); - expect(plan?.sessionsNeedingRepair).toBe(1); - }); - - it('stays suppressed when imported sessions already carry the current import format', async () => { - await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); - await writeFile( - join(src, '.migrated-to-kimi-code'), - JSON.stringify({ version: 1, target_path: tgt }), - 'utf-8', - ); - await seedImportedSession( - '{"type":"turn.prompt","agentId":"main","input":[],"origin":{"kind":"user"},"time":1}', - 2, - ); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).toBeNull(); - }); }); diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index 3be35d2fef2..6b508e889d4 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -279,7 +279,6 @@ function makeReport( sessionsAttempted: 50, sessionsMigrated: 50, sessionsAlreadyMigrated: 0, - sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, @@ -316,17 +315,38 @@ describe('MigrationScreenComponent — result phase', () => { expect(out).toContain('2 kimi-cli plugins'); }); - it('renders a repaired-sessions line and never claims nothing-to-migrate for repairs', () => { + it('renders nothing-needed-migrating when every counter is zero', () => { const c = new MigrationScreenComponent({ plan: makePlan(), sourceHome: '/x/.kimi', targetHome: '/y/.kimi-code', onComplete: () => {}, }); - c._testShowResult(makeReport({ sessionsMigrated: 0, sessionsRepaired: 7 })); + c._testShowResult( + makeReport( + { sessionsAttempted: 0, sessionsMigrated: 0 }, + { + config: { + migrated: false, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + sourceUnreadable: false, + deviceIdCopied: false, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + userHistory: { copied: 0, skippedExisting: 0 }, + }, + ), + ); const out = c.render(80).join('\n'); - expect(out).toContain('7 sessions repaired'); - expect(out).not.toContain('Nothing needed migrating'); + expect(out).toContain('Nothing needed migrating'); }); it('renders migrated hooks in the ✓ line and dropped hooks as a warning', () => { diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 262e2a28194..50574b30993 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -18,6 +18,7 @@ import { } from '#/tui/goal-queue-store'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { getBuiltInPalette } from '#/tui/theme'; +import { UNCONFIRMED_FILE_CHANGES_WARNING } from '#/tui/constant/kimi-tui'; vi.mock('#/tui/goal-queue-store', () => ({ appendGoalQueueItem: vi.fn(async () => ({ @@ -375,6 +376,8 @@ describe('handleGoalCommand', () => { }); expect(s.setPermission).toHaveBeenCalledWith('auto'); expect(manualHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(manualHost.showNotice).toHaveBeenCalledWith('Permission mode: Never Ask'); + expect(manualHost.showStatus).toHaveBeenCalledWith(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); expect(manualHost.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); @@ -393,6 +396,8 @@ describe('handleGoalCommand', () => { ); }); expect(s.setPermission).not.toHaveBeenCalled(); + expect(manualHost.showNotice).not.toHaveBeenCalled(); + expect(manualHost.showStatus).not.toHaveBeenCalledWith(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); expect(manualHost.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); @@ -411,6 +416,8 @@ describe('handleGoalCommand', () => { }); expect(s.setPermission).toHaveBeenCalledWith('yolo'); expect(manualHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); + expect(manualHost.showNotice).toHaveBeenCalledWith('Permission mode: Ask When Needed'); + expect(manualHost.showStatus).toHaveBeenCalledWith(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); }); it('restores the previous permission mode when the goal fails to start', async () => { @@ -430,6 +437,10 @@ describe('handleGoalCommand', () => { }); expect(s.setPermission).toHaveBeenCalledWith('yolo'); expect(manualHost.setAppState).toHaveBeenLastCalledWith({ permissionMode: 'manual' }); + // The permissive-mode notice is deferred until the goal starts, so a failed + // start leaves no stale notice behind. + expect(manualHost.showNotice).not.toHaveBeenCalled(); + expect(manualHost.showStatus).not.toHaveBeenCalledWith(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); }); it('returns the command to the input box when a Manual-mode goal start is cancelled', async () => { diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index a4d00ea30ac..781f0ee8194 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { handleSwarmCommand } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { currentTheme } from '#/tui/theme'; +import { UNCONFIRMED_FILE_CHANGES_WARNING } from '#/tui/constant/kimi-tui'; const ENTER = '\r'; const ESCAPE = '\u001B'; @@ -44,6 +45,7 @@ function makeHost( requireSession: () => session, setAppState: vi.fn((patch: Record) => Object.assign(host.state.appState, patch)), showError: vi.fn(), + showNotice: vi.fn(), showStatus: vi.fn(), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), @@ -232,6 +234,8 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.showNotice).toHaveBeenCalledWith('Permission mode: Never Ask'); + expect(host.showStatus).toHaveBeenCalledWith(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); expect(host.state.swarmModeEntry).toBe('task'); expectSwarmMarker(host, 'Swarm activated'); }); @@ -251,6 +255,8 @@ describe('handleSwarmCommand', () => { expect(session.setPermission).not.toHaveBeenCalled(); expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.showNotice).not.toHaveBeenCalled(); + expect(host.showStatus).not.toHaveBeenCalledWith(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); expect(host.state.swarmModeEntry).toBe('task'); expectSwarmMarker(host, 'Swarm activated'); }); @@ -271,6 +277,8 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.showNotice).toHaveBeenCalledWith('Permission mode: Ask When Needed'); + expect(host.showStatus).toHaveBeenCalledWith(UNCONFIRMED_FILE_CHANGES_WARNING, 'warning'); expect(host.state.swarmModeEntry).toBe('task'); expectSwarmMarker(host, 'Swarm activated'); }); diff --git a/apps/kimi-code/test/tui/components/messages/notice.test.ts b/apps/kimi-code/test/tui/components/messages/notice.test.ts index 09f727556a7..b915a2b3b7a 100644 --- a/apps/kimi-code/test/tui/components/messages/notice.test.ts +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -23,6 +23,18 @@ describe('NoticeComponent', () => { expect(lines[1]).toContain('Plan mode: ON'); expect(lines[2]).toContain('Plan will be created here: /tmp/plans/test-plan.md'); }); + + it('indents every line of a multi-line detail, not just the first', () => { + const component = new NoticeMessageComponent('Title', 'First line.\nSecond line.'); + + const lines = component.render(120).map((line) => strip(line)); + const titleColumn = lines.find((line) => line.includes('Title'))?.indexOf('Title'); + const firstColumn = lines.find((line) => line.includes('First line.'))?.indexOf('First line.'); + const secondColumn = lines.find((line) => line.includes('Second line.'))?.indexOf('Second line.'); + expect(titleColumn).toBeDefined(); + expect(firstColumn).toBe(titleColumn); + expect(secondColumn).toBe(titleColumn); + }); }); describe('CronMessageComponent', () => { 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 0be3b47c4fe..0528888f14f 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 @@ -2613,6 +2613,9 @@ command = "vim" expect(driver.state.appState).toMatchObject({ permissionMode: 'yolo', }); + expect(stripSgr(renderTranscript(driver))).toContain( + 'In this mode, Kimi Code can modify or delete files without your confirmation', + ); expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'ask-when-needed' }); expect(harness.track).not.toHaveBeenCalledWith('yolo_toggle', expect.anything()); }); @@ -2856,6 +2859,9 @@ command = "vim" expect(transcript).not.toContain('hello'); expect(transcript).not.toContain('Cannot undo 10 prompts'); expect(transcript).toContain('Never Ask mode: ON'); + expect(transcript).toContain( + 'In this mode, Kimi Code can modify or delete files without your confirmation', + ); expect(driver.state.appState.permissionMode).toBe('auto'); }); diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 3393d57ce59..2be22764557 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.7.5 + +### Patch Changes + +- [#3453](https://github.com/MoonshotAI/kimi-code/pull/3453) [`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606) Thanks [@Grapedge](https://github.com/Grapedge)! - Highlight matched characters in @ file suggestions and allow folders to be inserted as mentions. + +- [#3453](https://github.com/MoonshotAI/kimi-code/pull/3453) [`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606) Thanks [@Grapedge](https://github.com/Grapedge)! - Show the image/video picker entry in the @ menu only before a search query is typed, and remove the Browse folders mode. + +- [#3453](https://github.com/MoonshotAI/kimi-code/pull/3453) [`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606) Thanks [@Grapedge](https://github.com/Grapedge)! - Fix the @ and / suggestion lists jittering when the mouse rests at the scroll edge. + +- Updated dependencies [[`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606)]: + - @moonshot-ai/kimi-code-sdk@0.20.0 + ## 0.7.4 ### Patch Changes diff --git a/apps/vscode/package.json b/apps/vscode/package.json index c3f81a5b47e..1b28677a937 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.7.4", + "version": "0.7.5", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/apps/vscode/src/migration/legacy-migration.manager.ts b/apps/vscode/src/migration/legacy-migration.manager.ts index 496c7faf6d7..af741a68c77 100644 --- a/apps/vscode/src/migration/legacy-migration.manager.ts +++ b/apps/vscode/src/migration/legacy-migration.manager.ts @@ -5,7 +5,6 @@ import { isAbsolute, join, resolve, win32 } from "node:path"; import { detectMigration, runMigration, - countImportedSessionsNeedingRepair, defaultPlansSourceDir, shouldSuppressMigration, type MigrationPlan, @@ -278,12 +277,6 @@ export class LegacyMigrationManager { const oauthLoginsRequiringRelogin: LegacyMigrationReauthItem[] = []; const mcpOauthServersRequiringReauth: LegacyMigrationReauthItem[] = []; - // Sessions an older migrator left without turn-structure records are - // unfinished migration work; a repair need lifts marker suppression. - const sessionsNeedingRepair = await countImportedSessionsNeedingRepair( - this.targetHome, - ).catch(() => 0); - for (const candidate of candidates) { const sourceCheck = await checkSourceDirectory(candidate.sourceHome); if (sourceCheck === "missing") continue; @@ -353,7 +346,6 @@ export class LegacyMigrationManager { if ( !ignoreMarker && - sessionsNeedingRepair === 0 && shouldSuppressMigration({ sourceHome: candidate.sourceHome, targetHome: this.targetHome, @@ -365,7 +357,7 @@ export class LegacyMigrationManager { pending.push({ preview, - plan: { ...plan, sessionsNeedingRepair }, + plan, }); } @@ -565,8 +557,7 @@ function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): Legac skills += summary.skills.copied; planFiles += summary.plans.copied; sessions += summary.sessions.sessionsMigrated; - alreadyMigratedSessions += - summary.sessions.sessionsAlreadyMigrated + summary.sessions.sessionsRepaired; + alreadyMigratedSessions += summary.sessions.sessionsAlreadyMigrated; skippedItems += summary.userHistory.skippedExisting + summary.skills.skippedExisting + diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index e1577acb905..71329bc1bf4 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -60,11 +60,13 @@ When the agent calls a tool that has side effects — modifying files, running c Approvals are not triggered for regular tool calls in Ask When Needed mode, nor for writes to plan files in Plan mode. -### Ask When Needed / Never Ask mode +### The three permission modes -**Ask When Needed mode** (`/ask-when-needed`) auto-approves regular tool calls, making it suitable for batch tasks you know are safe. It still asks before sensitive actions — accessing sensitive files such as `.env` or SSH keys, running dangerous commands such as `shutdown` or `rm -rf`, or exiting Plan mode — and the agent can still ask you questions. +**Always Ask mode** (formerly Manual) is the default: read-only operations run automatically, while every other action — editing files, running commands — asks for your confirmation one by one. Use it when you want full control over every change. -**Never Ask mode** (`/never-ask`) is the fully unattended mode: every tool approval is handled automatically, including sensitive files and plan exits, and the agent never asks you questions — it decides everything on its own. The only exception is the built-in dangerous-command guard: commands such as `shutdown`, `reboot`, or `rm -rf` are always blocked in Never Ask mode, and always require your confirmation in Always Ask and Ask When Needed mode. +**Ask When Needed mode** (formerly YOLO), toggled with `/ask-when-needed`, auto-approves regular tool calls, making it suitable for batch tasks you know are safe. It still asks before sensitive actions — accessing sensitive files such as `.env` or SSH keys, running dangerous commands such as `shutdown` or `rm -rf`, or exiting Plan mode — and the agent can still ask you questions. + +**Never Ask mode** (formerly Auto), toggled with `/never-ask`, is the fully unattended mode: every tool approval is handled automatically, including sensitive files and plan exits, and the agent never asks you questions — it decides everything on its own. The only exception is the built-in dangerous-command guard: commands such as `shutdown`, `reboot`, or `rm -rf` are always blocked in Never Ask mode, and always require your confirmation in Always Ask and Ask When Needed mode. ## Mode switching diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 909dbe956c9..b29cc89133b 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,36 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.40.1 (2026-09-02) + +### Bug Fixes + +- Fix the condition for showing the kimi-cli migration prompt. + +## 0.40.0 (2026-09-02) + +### Features + +- web: Add a Plugins panel to Settings for browsing the plugin marketplace and installing, enabling, disabling, and removing plugins. +- web: Support activating multiple skills from a single message. +- Add the `kimi session list` command to list sessions from the command line. +- Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): the agent no longer enters tower mode on its own — turn it on with `/tower on` or `/tower `. +- The subagent model setting (`[secondary_model]`) graduates from experimental to stable. +- Block dangerous shell commands such as shutdown, reboot, or rm -rf in Auto mode, and always ask before running them in Manual and YOLO modes; disable the guard with `[permission] dangerous_command_guard = false` or `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false`. + +### Polish + +- Preserve comments, key order, and formatting in config.toml when configuration values are updated. +- Remove the workspace restriction on the Bash tool's cwd parameter. +- Default the workspace trust prompt selection to "Trust this folder" instead of "Don't trust". +- The `kimi acp` subcommand no longer honors `KIMI_CODE_LEGACY_FLAG`; it always runs on the default agent engine. +- web: Add a code wrap toggle to the diff panel and streamline its header. + +### Bug Fixes + +- Honor explicit `[experimental]` config entries over the `KIMI_CODE_EXPERIMENTAL_FLAG` master switch, so a flag set to `false` in config.toml stays off; per-feature `KIMI_CODE_EXPERIMENTAL_` variables still override both. +- 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.39.1 (2026-08-28) ### Bug Fixes diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index dbfb509fb85..7449e38a9c3 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -98,7 +98,7 @@ timeout = 5 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `default_model` | `string` | — | 默认模型别名,必须在 `models` 中定义 | -| `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`(始终询问:仅自动读取,其余操作逐一向你确认)、`yolo`(必要时询问:自动完成常规修改和命令;高危操作、提问和计划仍会问你)、`auto`(完全自动:完全不打断,所有操作和判断自动完成,但危险命令仍会被拒绝) | +| `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`("Always Ask":仅自动读取,其余操作逐一向你确认)、`yolo`("Ask When Needed":自动完成常规修改和命令;高危操作、提问和计划仍会问你)、`auto`("Never Ask":完全不打断,所有操作和判断自动完成,但危险命令仍会被拒绝) | | `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 | | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | | `extra_skill_dirs` | `array` | — | 额外 Skill 搜索目录,叠加到默认目录之上 | diff --git a/docs/zh/configuration/overrides.md b/docs/zh/configuration/overrides.md index a780a7e5398..402c097a597 100644 --- a/docs/zh/configuration/overrides.md +++ b/docs/zh/configuration/overrides.md @@ -55,8 +55,8 @@ Kimi Code CLI 有三个地方可以影响运行参数:配置文件、命令行 | --- | --- | | `-S, --session [id]` | 恢复指定会话;不带 id 时进入交互式选择 | | `-c, --continue` | 续上当前目录的上一次会话 | -| `-y, --yolo` | 「必要时询问」模式:常规修改和命令自动完成,Agent 仍可能提问 | -| `--auto` | 「完全自动」模式:完全不打断,Agent 不会向用户提问 | +| `-y, --yolo` | "Ask When Needed" 模式:常规修改和命令自动完成,Agent 仍可能提问 | +| `--auto` | "Never Ask" 模式:完全不打断,Agent 不会向用户提问 | | `--plan` | 以 Plan 模式启动 | | `-m, --model ` | 指定本次使用的模型别名 | | `-p, --prompt ` | 非交互模式:执行单条提示词后退出 | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 694b3198024..7d8d119aba6 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -18,7 +18,7 @@ Kimi Code CLI 内置三种 subagent,开箱即用,分别面向不同任务形 subagent 由 main agent 自动调度——根据任务复杂度、上下文消耗和子任务的独立性,在适当时机派发,无需用户手动指定。 -每次派发都会在终端以审批请求的形式呈现(除非命中 allow 规则或处于「必要时询问」模式),方便你审视任务描述。你也可以在对话中直接指示 main agent 使用特定 subagent,例如"先用 explore 把相关文件梳理一遍再动手"。 +每次派发都会在终端以审批请求的形式呈现(除非命中 allow 规则或处于 "Ask When Needed" 模式),方便你审视任务描述。你也可以在对话中直接指示 main agent 使用特定 subagent,例如"先用 explore 把相关文件梳理一遍再动手"。 subagent 支持在后台运行:完成后结果自动回到 main agent,无需手动轮询。也可以唤回已有的 subagent 实例继续推进同一任务。 diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index f09da69c36f..02dc59015a6 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -100,7 +100,7 @@ pattern = "mcp__filesystem__write_file" - 对高风险工具(写文件、执行命令等)维持手动审批,避免用 `mcp__*` 通配放行全部工具 ::: warning 注意 -在「必要时询问」模式下,MCP 工具调用会被自动批准。仅在完全信任所接入的 MCP server 时使用此模式。 +在 "Ask When Needed" 模式下,MCP 工具调用会被自动批准。仅在完全信任所接入的 MCP server 时使用此模式。 ::: ## 下一步 diff --git a/docs/zh/customization/themes.md b/docs/zh/customization/themes.md index adb876d25de..2fd5e843075 100644 --- a/docs/zh/customization/themes.md +++ b/docs/zh/customization/themes.md @@ -17,7 +17,7 @@ Kimi Code CLI 可以使用内置配色,也可以使用自定义 JSON 主题文 | `border` | `#5A5A5A` | `#737373` | 面板与编辑器的普通边框、Markdown 分隔线 | | `borderFocus` | `#E8A838` | `#92660A` | 聚焦/注意边框,目前仅审批面板使用 | | `success` | `#4EC87E` | `#0E7A38` | 成功态。`✓`、已启用、完成 | -| `warning` | `#E8A838` | `#92660A` | 警告态。「必要时询问」/「完全自动」徽章、过期标记、Plan 模式提示 | +| `warning` | `#E8A838` | `#92660A` | 警告态。"Ask When Needed" / "Never Ask" 徽章、过期标记、Plan 模式提示 | | `error` | `#E85454` | `#B91C1C` | 错误态。错误信息、失败的工具输出 | | `diffAdded` | `#4EC87E` | `#0E7A38` | diff 新增行 | | `diffRemoved` | `#E85454` | `#B91C1C` | diff 删除行 | diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md index 57b0e3d6589..6b4f0c0b25f 100644 --- a/docs/zh/guides/getting-started.md +++ b/docs/zh/guides/getting-started.md @@ -170,6 +170,6 @@ npm uninstall -g @moonshot-ai/kimi-code ## 下一步 -- [交互与输入](./interaction.md) — 输入框操作、审批流程、Plan 模式和必要时询问模式详解 +- [交互与输入](./interaction.md) — 输入框操作、审批流程、Plan 模式和 "Ask When Needed" 模式详解 - [会话与上下文](./sessions.md) — 恢复会话、上下文压缩、导出会话 - [常见使用案例](./use-cases.md) — 典型任务的 prompt 示例 diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 164e8662a9b..ceae4be5980 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -33,7 +33,7 @@ Kimi Code CLI 支持在输入框中直接粘贴图片和视频,让 AI 结合 | `/undo` | 撤销最近的提示词 | | `/model` | 切换当前会话使用的模型 | | `/plan` | 切换 Plan 模式(先出计划再动手) | -| `/ask-when-needed` | 切换「必要时询问」模式(常规修改和命令自动完成) | +| `/ask-when-needed` | 切换 "Ask When Needed" 模式(常规修改和命令自动完成) | | `/goal` | 开始或管理目标模式 | | `/help` | 查看全部命令 | @@ -58,13 +58,15 @@ Agent 调用会产生副作用的工具(修改文件、执行命令等)时 - **会话内放行**:选「Approve for this session」,本次会话内同类调用不再询问 - **永久规则**:在[配置文件](../configuration/config-files.md#permission)预置 allow / deny 规则 -「必要时询问」模式下的普通工具调用、Plan 模式下对计划文件的写入,不触发审批。 +"Ask When Needed" 模式下的普通工具调用、Plan 模式下对计划文件的写入,不触发审批。 -### 必要时询问 / 完全自动 模式 +### 三种权限模式 -**必要时询问**(`/ask-when-needed`)自动批准普通工具调用,适合已知安全的批处理任务。敏感操作仍会询问——例如访问 `.env`、SSH 私钥等敏感文件、执行 `shutdown`、`rm -rf` 这类危险命令,或退出 Plan 模式——Agent 也仍可能向你提问。 +**"Always Ask"**(始终询问,原 Manual)是默认模式:只读操作自动放行,修改文件、执行命令等其余操作都会逐一向你确认,适合需要全程掌控每个改动的场景。 -**完全自动**(`/never-ask`)是完全无人值守模式:所有工具审批自动处理,包括敏感文件和计划退出,且 Agent 不会向你提问,完全由它自己做决定。唯一的例外是内置的危险命令拦截:`shutdown`、`reboot`、`rm -rf` 这类命令在完全自动模式下会被直接拒绝,在始终询问和必要时询问模式下必须经你确认。 +**"Ask When Needed"**(必要时询问,原 YOLO)用 `/ask-when-needed` 切换,自动批准普通工具调用,适合已知安全的批处理任务。敏感操作仍会询问——例如访问 `.env`、SSH 私钥等敏感文件、执行 `shutdown`、`rm -rf` 这类危险命令,或退出 Plan 模式——Agent 也仍可能向你提问。 + +**"Never Ask"**(完全自动,原 Auto)用 `/never-ask` 切换,是完全无人值守模式:所有工具审批自动处理,包括敏感文件和计划退出,且 Agent 不会向你提问,完全由它自己做决定。唯一的例外是内置的危险命令拦截:`shutdown`、`reboot`、`rm -rf` 这类命令在 "Never Ask" 模式下会被直接拒绝,在 "Always Ask" 和 "Ask When Needed" 模式下必须经你确认。 ## 模式切换 @@ -76,7 +78,7 @@ Plan 模式下,Agent 先输出行动计划,等待你确认后才动手修改 - 切换:`Shift-Tab` 或 `/plan` - 清除当前计划:`/plan clear`(仅空闲时) -Agent 输出方案后会等待你审批——可批准执行、拒绝、或要求修改。退出 Plan 模式需要你确认,即使开启了必要时询问模式也不例外。完全自动模式例外:计划退出会自动批准,并在记录中标记为 "Auto-approved"。 +Agent 输出方案后会等待你审批——可批准执行、拒绝、或要求修改。退出 Plan 模式需要你确认,即使开启了 "Ask When Needed" 模式也不例外。"Never Ask" 模式例外:计划退出会自动批准,并在记录中标记为 "Auto-approved"。 ### Shell 模式 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 7239346ef7e..ce1caba10ad 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -20,8 +20,8 @@ kimi [options] | `--model ` | `-m` | 为本次启动指定模型别名。省略时新会话使用配置文件中的 `default_model` | | `--prompt ` | `-p` | 非交互执行单次 prompt,并把 Assistant 输出流式写到 stdout。该模式不会打开 TUI | | `--output-format ` | | 设置非交互输出格式,支持 `text` 与 `stream-json`。仅可与 `--prompt` 一起使用,默认 `text` | -| `--yolo` | `-y` | 以「必要时询问」模式启动:常规修改和命令自动完成;高危操作、提问和计划仍会问你 | -| `--auto` | | 以「完全自动」模式启动:完全不打断,所有操作和判断自动完成 | +| `--yolo` | `-y` | 以 "Ask When Needed" 模式启动:常规修改和命令自动完成;高危操作、提问和计划仍会问你 | +| `--auto` | | 以 "Never Ask" 模式启动:完全不打断,所有操作和判断自动完成 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | | `--agent ` | | 以指定 Agent 作为 main agent 启动新会话。不能与 `--session`/`--continue` 同时使用 | @@ -43,7 +43,7 @@ kimi [options] - `--prompt` 不能与 `--yolo`、`--auto` 或 `--plan` 同时使用——非交互模式固定使用 `auto` 权限 - `--output-format` 只能与 `--prompt` 一起使用 -恢复会话时,可以通过 `--auto`、`--yolo` 或 `--plan` 覆盖原会话保存的权限或计划模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到「完全自动」模式。 +恢复会话时,可以通过 `--auto`、`--yolo` 或 `--plan` 覆盖原会话保存的权限或计划模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到 "Never Ask" 模式。 ## 典型用法 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 986e8d755fc..12226bca60a 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -45,12 +45,12 @@ | 命令 | 别名 | 说明 | 随时可用 | | --- | --- | --- | --- | -| `/ask-when-needed [on\|off]` | `/yolo`、`/yes` | 切换「必要时询问」模式。不带参数时翻转;显式传 `on`/`off` 时强制设置。开启后常规修改和命令自动完成;高危操作、提问和计划仍会问你 | 是 | -| `/never-ask [on\|off]` | `/auto` | 切换「完全自动」模式。开启后完全不打断,所有操作和判断自动完成 | 是 | +| `/ask-when-needed [on\|off]` | `/yolo`、`/yes` | 切换 "Ask When Needed" 模式。不带参数时翻转;显式传 `on`/`off` 时强制设置。开启后常规修改和命令自动完成;高危操作、提问和计划仍会问你 | 是 | +| `/never-ask [on\|off]` | `/auto` | 切换 "Never Ask" 模式。开启后完全不打断,所有操作和判断自动完成 | 是 | | `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时翻转;显式传 `on`/`off` 时强制设置。单纯切换不会创建空计划文件 | 是 | | `/plan clear` | — | 清除当前 plan 方案 | 否 | | `/swarm on\|off` | — | 开启或关闭 swarm mode,但不发送提示词。 | 是 | -| `/swarm ` | — | 先开启 swarm mode,再把 `` 作为普通提示词发送。如果该轮次正常完成,swarm mode 会自动关闭。若当前是 `manual` 权限模式,启动前会提示是否切换到「必要时询问」或「完全自动」模式。 | 否 | +| `/swarm ` | — | 先开启 swarm mode,再把 `` 作为普通提示词发送。如果该轮次正常完成,swarm mode 会自动关闭。若当前是 `manual` 权限模式,启动前会提示是否切换到 "Ask When Needed" 或 "Never Ask" 模式。 | 否 | | `/goal [...]` | — | 开始或管理目标模式 | 见下文 | ::: warning 注意 diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index fd5bbc6569b..ca034761830 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -2,7 +2,7 @@ 内置工具是 Kimi Code CLI 随核心引擎提供的工具集,无需安装 MCP server 即可使用。Agent 在每次对话中会根据任务需要自动选择并调用这些工具;用户可以通过权限审批界面查看每次工具调用的细节。 -与 MCP 工具相比,内置工具由运行时直接管理,生命周期与会话绑定,无需外部进程。两者都遵循统一的审批机制:**只读类工具**(如 `Read`、`Grep`、`Glob`)默认自动放行,**写入与执行类工具**(如 `Write`、`Edit`、`Bash`)默认需要用户审批。「必要时询问」模式下普通工具调用的审批会被跳过,但 Plan 模式下的退出审批不受影响。 +与 MCP 工具相比,内置工具由运行时直接管理,生命周期与会话绑定,无需外部进程。两者都遵循统一的审批机制:**只读类工具**(如 `Read`、`Grep`、`Glob`)默认自动放行,**写入与执行类工具**(如 `Write`、`Edit`、`Bash`)默认需要用户审批。"Ask When Needed" 模式下普通工具调用的审批会被跳过,但 Plan 模式下的退出审批不受影响。 ## 文件类 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 3a7f1a26005..2d4ea23584b 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,36 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.40.1(2026-09-02) + +### 修复 + +- 修复 kimi-cli 迁移完成或关闭后仍重复弹出迁移提示的问题。 + +## 0.40.0(2026-09-02) + +### 新功能 + +- Web 版设置新增「插件」面板:可浏览插件市场并安装、启停、移除插件。 +- 支持在一条消息中同时激活多个技能。 +- 新增 `kimi session list` 命令,可在命令行直接列出会话。 +- Tower 模式(实验性)行为调整:agent 不再自行进入,需用 `/tower on` 或 `/tower ` 显式开启。 +- 子代理设置(`[secondary_model]`)功能由实验性转为正式。 +- 新增危险命令护栏:Auto 模式直接拦截 shutdown、reboot、rm -rf 等危险命令,Manual 与 YOLO 模式执行前必定询问;可用 `[permission] dangerous_command_guard = false` 或 `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false` 关闭。 + +### 优化 + +- 更新配置时完整保留 config.toml 的注释、键顺序与格式。 +- Bash 工具的 cwd 参数不再限制在工作区内。 +- 工作区信任弹窗默认选中「Trust this folder」。 +- `kimi acp` 子命令不再识别 `KIMI_CODE_LEGACY_FLAG`,始终运行在默认 agent 引擎。 +- Web 版 Diff 面板新增代码折行开关,并精简了面板头部。 + +### 修复 + +- 修复实验开关优先级:config.toml 中显式设为 `false` 的 `[experimental]` 条目现在稳定优先于 `KIMI_CODE_EXPERIMENTAL_FLAG` 总开关(单项 `KIMI_CODE_EXPERIMENTAL_` 变量仍覆盖两者)。 +- 修复了一些已知问题,并做了若干细节优化。更详细的变更记录见 [GitHub](https://github.com/MoonshotAI/kimi-code/blob/main/apps/kimi-code/CHANGELOG.md)。 + ## 0.39.1(2026-08-28) ### 修复 diff --git a/packages/agent-core-v2/docs/Permission.md b/packages/agent-core-v2/docs/Permission.md deleted file mode 100644 index 546fb97b356..00000000000 --- a/packages/agent-core-v2/docs/Permission.md +++ /dev/null @@ -1,333 +0,0 @@ -# 权限系统设计(Permission) - -本文系统整理 agent-core 权限系统的目标方案,并与 `packages/agent-core`(v1)现状对比。结论先行: - -> **权限系统应是一个「可组合、可注册的责任链(微内核)」**:内核只负责按顺序跑链、首个命中赢;具体权限维度(policy)由各自的 Domain Service 通过注册表插入;工具只需在 `resolveExecution` 里声明标准化的资源访问(`accesses`),通用维度集中消费这份元数据。 -> -> **链只裁决危险程度**。policy 节点回答的是「这个调用有多危险、用户能否逐次豁免这个判断」——它产出的 `ask`/`deny` 永远可被用户豁免。**Harness 约束不是权限**:运行机制为自身正确性施加的限制(plan 模式禁写、AgentSwarm 批量排他、btw side-question fork 禁工具、goal 预算拒绝)产出的是无 ask 通道、用户无法逐次豁免的硬 deny,它们以 `onBeforeExecuteTool` veto 监听器挂在各自 domain,用 `event.veto(...)` 表态(先例:`goalService.ts` 的预算/过期拒绝)。产物审批(plan review、goal-start review)同样不是权限:由 owning domain 用 cold 的 `event.waitUntil(factory)` 拦截自己的工具、直接驱动共享的 `IAgentToolApprovalService` 审批往返——审批只可能在没有任何监听器 veto 该调用之后才开始。 -> -> **不引入 Casbin**——因为这里「难的是决策行为」(续体、副作用、RPC、状态机),不是「匹配 + 标量决策」。 - ---- - -## 一、背景与问题定义 - -权限系统回答一个问题:**对于每一次工具调用,在当前 agent、当前 mode 下,放行 / 拒绝 / 询问用户?** - -这个决策有三个特点,决定了它的架构取向: - -1. **决策携带行为**。返回 `ask` 不是一个枚举值,而是一条含 RPC 往返、hook、telemetry、状态写入、续体的工作流;返回 `deny` 可能是执行了一段外部 hook 的结果。 -2. **策略异质**。有的查工具名集合,有的数同批 AgentSwarm 个数,有的跑 hook,有的检查 plan 状态机——没有统一的 `(sub, obj, act)` 形状。 -3. **多 agent × 多 mode × 外部扩展**。不同 agent / mode 需要不同权限,且要允许外部(组织管理员、插件)解耦地贡献规则或行为。 - ---- - -## 二、现状(agent-core v1) - -代码位于 `packages/agent-core/src/agent/permission/`。 - -### 2.1 架构:有序责任链 + 首个命中赢 - -`PermissionManager`(`index.ts`)持有一组 `PermissionPolicy`,决策时顺序遍历,第一个返回非 `undefined` 的 policy 胜出: - -```ts -// index.ts evaluatePolicies -for (const policy of this.policies) { - const result = await policy.evaluate(context); - if (result !== undefined) return { policyName: policy.name, result }; -} -``` - -每个 policy 是一个实现 `PermissionPolicy` 接口的类,`evaluate(context)` 不适用就返回 `undefined`(传给下一个)。`PermissionPolicyResult` 不是标量,而是可携带续体和副作用的「行为包」: - -```ts -// types.ts -type PermissionPolicyResult = - | { kind: 'approve'; reason?; executionMetadata? } - | { kind: 'deny'; reason?; message? } - | { kind: 'ask'; reason?; resolveApproval?; resolveError? }; -``` - -### 2.2 11 个权限维度(19 个 policy) - -链目前在 `policies/index.ts#createPermissionDecisionPolicies()` 中**硬编码**,顺序即优先级。19 个 policy 可归并为 11 个权限维度: - -| # | 维度 | 对应 policy | 决策看什么 | -|---|---|---|---| -| 1 | 外部钩子否决 | `pre-tool-call-hook` | 用户 `PreToolUse` hook 是否返回 block | -| 2 | 工具批量排他 | `agent-swarm-exclusive-deny`、`swarm-mode-agent-swarm-approve` | 同批工具结构(AgentSwarm 须单独)+ swarm 模式 | -| 3 | 运行模式姿态 | `auto-mode-approve`、`yolo-mode-approve`、`auto-mode-ask-user-question-deny` | `permission.mode` | -| 4 | Plan 模式约束 | `plan-mode-guard-deny`、`plan-mode-tool-approve`、`exit-plan-mode-review-ask` | `planMode.isActive` + plan 文件路径 + review 状态 | -| 5 | Goal 启动审批 | `goal-start-review-ask` | `tool === CreateGoal` 且非 auto | -| 6 | 静态配置规则 | `user-configured-deny/ask/allow` | 用户/项目/turn 配置的 DSL 规则 | -| 7 | 会话批准记忆 | `session-approval-history` | 本会话 "approve for session" 缓存 | -| 8 | 敏感/特殊路径 | `sensitive-file-access-ask`、`git-control-path-access-ask` | 工具访问的文件路径 | -| 9 | 工具内在风险 | `default-tool-approve` | 工具名 ∈ 默认安全集合 | -| 10 | 工作区写信任 | `git-cwd-write-approve` | POSIX + git worktree + cwd 内写 | -| 11 | 兜底 | `fallback-ask` | 无(默认 ask) | - -链的顺序是一条**从高到低的安全级联**:外部强制 → 结构性拒绝 → 状态机拒绝 → 静态 deny → mode 放行 → 会话记忆放行 → 静态 ask → 静态 allow → 流程放行 → 敏感路径 ask → 默认放行 → 兜底 ask。 - -### 2.3 资源访问声明:`resolveExecution` + `accesses` - -工具通过 `resolveExecution(input)` 在执行前声明自己访问的资源(`packages/agent-core/src/loop/types.ts`、`tool-access.ts`): - -```ts -interface RunnableToolExecution { - readonly accesses?: ToolAccesses; // 资源 + 操作 - readonly matchesRule?: (ruleArgs) => boolean; - readonly approvalRule: string; - readonly execute: (ctx) => Promise; -} -``` - -`ToolAccesses` 是 `ToolResourceAccess[]`,目前支持 `file` 与 `all` 两类资源(详见 §5.5)。权限维度(如 `sensitive-file-access-ask`、`git-cwd-write-approve`)读 `context.execution.accesses` 做判断。 - -### 2.4 优势 - -- **清晰可审计**:顺序显式,每个 policy 旁有注释解释其位置,安全姿态一目了然。 -- **首个命中短路**:大多数调用(如只读工具)在 `default-tool-approve` 即返回,性能好。 -- **行为表达力强**:`ask` 可携带 `resolveApproval` 续体、`executionMetadata`、自定义消息和副作用。 - -### 2.5 痛点 - -1. **链硬编码**。19 个 policy 在一个函数里 `new`,外部无法贡献。 -2. **mode 是 policy 内部的 `if`**。`YoloModeApprove` / `AutoModeApprove` 各自 `if (mode !== 'x') return`,"不同 mode 不同链"只能靠塞更多 self-guard 的 policy。 -3. **没有按 agent 区分链的入口**(只有散落的 `agent.type === 'sub'` 判断)。 -4. **没有外部扩展点**。唯一的外部介入是 `PreToolUse` hook(占 guard 一个固定槽位)。 -5. **bash/write 等通用工具的维度集中在核心**,工具自己只声明 `accesses`,不知道维度存在——这是优点,但也意味着新增维度要改核心。 - ---- - -## 三、为什么不是 Casbin - -Casbin 的两个卖点(`policy_effect` 和灵活 priority)在当前业务下都落不到实处。 - -### 3.1 `policy_effect` 用不上 - -`policy_effect` 解决「多规则命中后如何组合」。但 agent-core 的组合逻辑是**固定的安全级联**,且真正的复杂度在每条 policy 的 `evaluate` 行为里,Casbin 表达式吸收不了。更重要的是:组合顺序是安全相关的、故意写死的姿态,不希望外部改动——外部可调的安全旋钮已通过 `mode` + allow/deny/ask 规则暴露。 - -### 3.2 灵活 priority 用不上 - -priority 的痛点是「多模块各自贡献规则时数字撞车」。agent-core 当前没有插件注入点、没有多主体/RBAC,主体固定(agent/用户),不存在撞车问题。Casbin 的 `(sub, obj, act)`、`g()`、domain 等抽象在这里空转。 - -### 3.3 根本性不匹配:决策不是标量 - -`enforce()` 的契约是「输入请求 → 输出 effect」。agent-core 的决策是**行为包**: - -| policy | 返回 `ask` 后的真实行为 | -|---|---| -| `requestToolApproval` | 触发 hook → 异步 RPC 问用户 → 记 telemetry → 写 records/replay → 可选写会话缓存 → 调续体 | -| `goal-start-review-ask` | 弹菜单 → 根据回答**切换 permission mode** → 放行 | -| `exit-plan-mode-review-ask` | 推进 plan 状态机 → 记多种 telemetry → **合成工具结果**短路执行 | -| `pre-tool-call-hook` | `deny` 是**异步执行外部 hook** 的结果 | - -这些续体、副作用、合成结果没有槽位放进 Casbin 的标量 effect。即便让 Casbin 算出 `ask`,外面仍需重写一整套把 `ask` 关联到行为的逻辑——Casbin 降级成枚举生成器。 - -### 3.4 Casbin 何时才值得 - -当「难的是匹配语义本身」时——角色继承、domain 隔离、ABAC 表达式、从 DB 加载策略——Casbin 才有用武之地。在此之前不引入。 - ---- - -## 四、设计模式定位 - -权限编排不是一个单一模式,而是分层组合: - -| 层 | 模式 | 作用 | -|---|---|---| -| 运行时决策 | **责任链(Chain of Responsibility)** | 多个候选处理者按顺序,首个命中赢,后续短路 | -| 单个处理者 | **策略(Strategy)** | 每个 policy 是「权限裁决」算法族的可互换实现 | -| 组装 / 外部扩展 | **插件 / 微内核(Plugin / Microkernel)** | 极简内核 + 明确扩展点 + 可插拔的 policy | -| 落地辅助 | **注册表(Registry)+ 工厂(Factory)** | 收集插件;按 (agent, mode) 现场组装链 | - -与 Casbin 的范式对比: - -- **Casbin = 单一 Strategy + 数据驱动**:所有决策走同一个 matcher 表达式,差异压成 policy rows(数据)。 -- **本方案 = 多 Strategy + 责任链组合**:每个 policy 是独立策略,差异靠代码,靠责任链组装。 - -行为密集型系统必须选后者——行为无法压成数据行。 - ---- - -## 五、目标方案 - -### 5.1 核心原则 - -1. **链编码「权限维度」,不编码「工具」**。新增工具不延长链;只有新增维度才加节点。 -2. **两条贡献路径**:高频琐碎的具体内容走**数据路径**(规则);低频有行为的新维度走**代码路径**(policy)。 -3. **guard/review 下链,风险上链**:Harness 约束与产物审批以 executor hook 挂在 owning domain(见 5.4);domain 贡献的**风险**维度才在 DI 中自注册 policy,镜像 v2 已有的「domain 自注册工具」。 -4. **工具声明资源,通用维度消费**:bash/write/read 等只声明 `accesses`,文件/安全维度集中判断。 - -### 5.2 核心抽象 - -```ts -type Phase = - | 'guard' | 'user-deny' | 'mode' | 'session' - | 'user-ask' | 'default' | 'fallback'; - -interface PermissionPolicyEntry { - name: string; - phase: Phase; - modes?: PermissionMode[]; // 声明在哪些 mode 生效(不再在 evaluate 里 if) - agentTypes?: AgentType[]; - factory: (accessor: ServicesAccessor) => PermissionPolicy; -} - -// App scope —— 收集所有 domain 的注册 -interface IPermissionPolicyRegistry { - register(entry: PermissionPolicyEntry): IDisposable; - list(): readonly PermissionPolicyEntry[]; -} -``` - -`PermissionPolicyService`(Agent scope)从硬编码列表改为「按 (agent, mode) 组装」: - -```ts -this.policies = registry.list() - .filter(e => !e.modes || e.modes.includes(mode)) - .filter(e => !e.agentTypes || e.agentTypes.includes(agentType)) - .sort(byPhaseThenRegistrationOrder) - .map(e => e.factory(accessor)); -``` - -要点: - -- `modes`/`agentTypes` 是**声明**,把现在 `YoloModeApprove` 里的 `if (mode !== 'yolo') return` 提到元数据。 -- `factory` 而非 `instance`:节点可能依赖 agent-scoped 服务(mode、rules),需在 Agent scope 实例化——对称 `IToolDefinitionRegistry`(App) 存 factory、`IToolService`(Agent) 实例化工具。 -- **不同 (agent, mode) 产出形状不同的链**:yolo 下 ask/fallback 阶段被物理过滤掉。 - -### 5.3 两条贡献路径 - -| 新增的是…… | 路径 | 链长变化 | -|---|---|---| -| 新工具、新组织规则、新用户偏好("禁 `Bash(curl *)`") | **数据路径**:往现有节点塞一条 `PermissionRule` | 不变 | -| 新横切行为(自定义审批 UI、审计日志、新 mode) | **代码路径**:注册一个新 policy 节点 | +1 | - -绝大部分增长走数据路径——节点数被「行为种类」约束,规则数才随具体情况增长(规则匹配是廉价的 Set/glob)。 - -### 5.4 Domain 维度:guard/review 走 executor veto 事件,风险维度走链注册 - -**Harness 约束与产物审批不再上链。** 拥有它们的 domain 注册一个 `onBeforeExecuteTool` veto 监听器,通过事件对象自行裁决: - -```ts -// src/plan/planService.ts —— 构造函数 -constructor(@IAgentToolExecutorService executor, ...) { - executor.onBeforeExecuteTool((event) => this.guardToolExecution(event)); -} -``` - -- veto 事件没有 id、没有排序契约。监听器用 `event.veto(result)`(先到先得,终止裁决)、`event.allow()`(终局放行,终止包括 permission gate 自己在内的一切后续表态)、`event.pass(metadata)`(留痕放行,不终止他人表态)或 `event.waitUntil(factory)`(申报需要等外部输入的挂起裁决)表态。 -- **guard(硬 deny)**:`event.veto(denyToolExecution(toolApproval.formatDenyMessage(...)))`。即时 veto 会压制所有待履行的 `waitUntil` factory——deny 之前绝不可能先弹出别人的审批。 -- **review(产物审批)**:拦截自家工具,`event.waitUntil(() => ...requestToolApproval(event, ask, origin))`。factory 是 cold 的——executor 只会在所有监听器都跑完且无人 veto/allow 后才履行它,所以 review 的 Interaction 只可能在调用已经确定要继续时发出;不审批的情形一律不表态,让用户规则继续生效。 -- **纯放行**:不要随便 `allow()`——把工具加进 `default-tool-approve` 白名单,保住用户 deny/ask 规则的优先权;`allow()` 留给 plan 文件写 guard 这种必须绕过整条权限链的场景。 - -**domain 贡献的风险维度仍走链**(下面的注册表路径):domain 状态会改变*危险度*结论的,经 `IPermissionPolicyRegistry` 自注册 policy,镜像 v2 里「domain 在构造函数中 `toolRegistry.register(...)`」的现成做法。复杂 domain 可对外只注册**一个复合节点**(Composite),内部跑小链,避免泄漏内部顺序到全局。 - -### 5.5 工具运行时声明资源(`resolveExecution` / `accesses`) - -工具在 `resolveExecution(input)` 里、执行前,用 `ToolAccesses.*` builder 声明访问的资源: - -```ts -// packages/agent-core/src/tools/builtin/file/write.ts -resolveExecution(args: WriteInput): ToolExecution { - const path = resolvePathAccessPath(args.path, { kaos, workspace, operation: 'write' }); - return { - accesses: ToolAccesses.writeFile(path), // 声明:写这个文件 - approvalRule: literalRulePattern(this.name, path), - matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, ...), - execute: () => this.execution(args, path), - }; -} -``` - -`ToolAccesses` 目前两类资源: - -```ts -type ToolResourceAccess = - | { kind: 'file'; operation: 'read'|'write'|'readwrite'|'search'; path: string; recursive?: boolean } - | { kind: 'all' }; // 无法枚举的副作用(悲观、全局排他) -``` - -**两条互补通道**: - -- **能枚举资源的**(write/read/edit/grep/glob)→ 用 `accesses`,通用文件维度自动覆盖。 -- **不能枚举资源的**(bash 跑任意命令)→ 不声明 `accesses`,改用 `matchesRule` DSL(如 `Bash(rm *)` 按命令串 glob)。 - -**kaos 的定位**:kaos 是执行环境抽象(fs/process/pathClass),供文件维度做路径归一化与判断,**不是权限维度抽象本身**。权限语义在 kaos 之上的「文件访问」层。 - -**v2 演进方向**:扩展 `ToolResourceAccess` 联合类型,让非文件资源也能结构化声明: - -```ts -type ToolResourceAccess = - | { kind: 'file'; operation: FileOp; path: string; recursive?: boolean } - | { kind: 'network'; operation: 'connect'; host: string } - | { kind: 'shell'; command: string } - | { kind: 'datastore'; operation: 'read'|'write'; table: string } - | { kind: 'all' }; -``` - -每新增一种资源类型,可对应加一个通用维度消费它;工具侧始终只负责**声明**。 - -### 5.6 维度归属 - -| 维度 | 拥有者 | 类型 | -|---|---|---| -| 外部钩子否决 | `externalHooks` domain | 通用 | -| 工具批量排他 | `swarm` domain —— `onBeforeExecuteTool` veto 监听器 | Harness 约束(链外) | -| Plan 写守卫 | `plan` domain —— `onBeforeExecuteTool` veto 监听器 | Harness 约束(链外) | -| Plan 审批 | `plan` domain —— 同监听器的 `waitUntil` + `toolApproval` | 产物审批(链外) | -| Goal 启动审批 | `goal` domain —— veto 监听器的 `waitUntil` + `toolApproval` | 产物审批(链外) | -| Goal 预算/过期拒绝 | `goal` domain —— `onBeforeExecuteTool` veto 监听器 | Harness 约束(链外) | -| btw 禁工具 | `btw` domain —— fork 上的 veto 监听器 | Harness 约束(链外) | -| 运行模式姿态(auto/yolo) | `permissionMode` domain(链节点,待「档位 × 路由」拆分) | 通用 | -| 静态配置规则 | `permissionRules` domain | 通用(数据路径) | -| 会话批准记忆 | `permissionRules` domain | 通用 | -| 敏感/特殊路径 | 通用「文件访问/安全」维度 | 通用(消费 `accesses`) | -| 工具内在风险 | 核心 permission(`default-tool-approve`) | 通用(消费工具声明) | -| 工作区写信任 | 通用「文件访问/安全」维度 | 通用(消费 `accesses`) | -| 兜底 | 核心 permission | 通用 | -| 审批往返 | `toolApproval` domain —— 供 gate 的 ask 与各域 review 共用 | 基础设施 | - -规律:**Harness 约束与产物审批跟着 owning domain 走 `onBeforeExecuteTool` veto 监听器;风险维度以 policy 上链(注册表落地后自注册);通用维度集中注册,靠工具声明的 `accesses` 跨工具生效。** - ---- - -## 六、现状 vs 方案 对比 - -| 方面 | 现状(v1) | 目标方案 | -|---|---|---| -| 链的构造 | `policies/index.ts` 硬编码 19 个 `new` | `IPermissionPolicyRegistry` 收集,`compose(agent, mode)` 组装 | -| mode 处理 | policy 内部 `if (mode !== 'x') return` | 声明式 `modes` 元数据,compose 时过滤 | -| 按 agent 区分 | 散落 `agent.type === 'sub'` | 声明式 `agentTypes` 元数据 | -| 外部扩展 | 仅 `PreToolUse` hook 一个固定槽 | 注册表开放注册 policy(代码)+ rule(数据) | -| Domain 维度 | 集中在核心文件 | guard/review 走 domain 自带 `onBeforeExecuteTool` veto 监听器;风险维度走 domain 自注册 policy | -| 工具维度 | 工具声明 `accesses`,维度集中 | 不变,扩展 `ToolResourceAccess` 资源类型 | -| 决策行为 | 续体 + 副作用(已具备) | 不变(这是必须保留的核心能力) | -| 运行时性能 | 顺序链 + 短路 | 不变;节点增多时可加工具名索引优化 | - -**不变的**:责任链内核、首个命中赢、`PermissionPolicyResult` 行为包、`resolveExecution`/`accesses` 机制。 - -**改变的**:链从「硬编码列表」变成「注册表 + 工厂组装」;mode/agent 从「内部 if」变成「声明式元数据」;维度归属从「核心集中」变成「domain 自注册」。 - ---- - -## 七、演进路径 - -渐进式,避免一步到位: - -1. ~~**Domain 维度下沉**~~(已完成)。plan guard/review、goal-start review、swarm 批量排他、btw deny-all 已从链上移出,以 `onBeforeExecuteTool` veto 监听器挂在各自 domain(即时 `veto`/`allow`/`pass` 表态 + cold `waitUntil` factory 承载审批往返);审批往返提取为共享的 `IAgentToolApprovalService`;`registerPolicy` 机制删除(btw 是唯一生产用例)。链上只剩 12 个危险度判定节点。 -2. **档位 × 路由拆分**。把「危险度档位」(只读/读写/yolo——`yolo-mode-approve` 的实质)与「交互路由」(`auto-mode-approve` / `auto-mode-ask-user-question-deny` 的实质:不经用户地路由 ask 与 review)拆开;路由层落在 `session/approval` broker 上,剩余 3 个 mode policy 在此步离开链。 -3. **注册表 + Composer(行为零变化)**。把 `PermissionPolicyService` 构造函数里硬编码的 `new`,改为从 `IPermissionPolicyRegistry` 读取并组装;mode 守门提升为 `modes` 元数据。获得多 agent/mode 可选链与外部注册入口。 -4. **第四步(按需):扩展资源类型**。当非文件资源(网络/DB/shell)需要结构化维度时,扩展 `ToolResourceAccess` 联合。 -5. **第五步(按需):匹配内核换 Casbin**。仅当外部规则真的需要 RBAC/ABAC 语义时,把数据路径的规则匹配内核换成 Casbin。不到此步不引。 - ---- - -## 八、待决问题 - -1. **Composite 节点的边界**:哪些 domain 内部用复合节点(隐藏子顺序),哪些直接注册多个 phase 节点? -2. **同 phase 多节点的排序**:注册顺序是否足够,还是需要显式 `order` 逃生舱? -3. **`ToolResourceAccess` 扩展节奏**:哪些非文件资源优先纳入(shell / network / datastore)? -4. **v1 → v2 迁移时机**:v2 权限子系统目前是 v1 类型/逻辑的薄包装,何时把 `accesses`、`PermissionPolicyResult` 等提升为正式 v2 类型? -5. **运行时性能阈值**:节点数达到多少时引入工具名索引(`byTool` 分派)优化?当前 12 个节点、首个命中短路,远未触及。 diff --git a/packages/agent-core-v2/docs/di-testing.md b/packages/agent-core-v2/docs/di-testing.md deleted file mode 100644 index 86681fc596d..00000000000 --- a/packages/agent-core-v2/docs/di-testing.md +++ /dev/null @@ -1,395 +0,0 @@ -# DI testing - -> Conventions for testing services built on the DI × Scope architecture. -> -> The goal of these rules is that a test exercises the **same path production -> uses**: a service is reached by its interface through the container, its -> `@IService` dependencies are resolved from the container, and — where the -> scope layer matters — through the scope tree. Tests that `new` a service and -> paper over its constructor with hand-rolled objects bypass that path and let -> the `registerScopedService(IX → Impl)` binding rot untested. - -`@IService` parameter decorators run under vitest (the build uses -`experimentalDecorators`), so fixtures declare dependencies exactly like -production code. There is **no** `param()` helper, no manual -`(Id as …)(Ctor, '', 0)`, and no capturing `accessor` inside a constructor to -synchronously `.get()` a peer — those are workarounds for a decorator -transform we already have. - -## The one rule - -**Resolve the system under test by its interface, through the container. Never -call `new` on a production service whose constructor carries `@IService` -dependencies.** - -```ts -// ✅ resolve by interface — the IX → Sut binding is exercised -ix.set(IMessageService, new SyncDescriptor(MessageService)); -const svc = ix.get(IMessageService); - -// ❌ construct the implementation directly — the registration is never run -const svc = new MessageService(stubContext); -``` - -Resolving by interface is what makes `registerScopedService(ISut, Sut, …)` part -of the test. Constructing the class directly (or via -`ix.createInstance(Sut)`) tests the class in isolation but leaves the binding, -the scope layer, and its `ScopeActivation` mode unverified. - -Pure functions, value objects, and services with **no** `@IService` -dependencies may be constructed directly. - -The only other exception is a test that genuinely needs **two independent -instances** of the same service with different dependencies (for example, -[`test/turn/turn.test.ts`](../test/turn/turn.test.ts) constructs two -`TurnService`s with different `ILoopRunner`s). A singleton-per-container -resolution cannot produce both, so `ix.createInstance(Impl)` is acceptable -there — annotate it with a comment explaining why. - -## Two harnesses - -Pick the harness by *whether the scope layer is part of what you are testing*. - -| Under test | Harness | Resolve the SUT with | -|---|---|---| -| A single service's behavior (unit) | `TestInstantiationService` (flat) | `ix.get(ISut)` after `ix.set(ISut, new SyncDescriptor(Sut))` | -| Cross-scope wiring, or which layer a service lives in | `createScopedTestHost` (scope tree) | `host..accessor.get(ISut)` | - -### Unit harness — `TestInstantiationService` - -Default for domain service unit tests. It is an `InstantiationService` that -also implements `ServicesAccessor` (so you can `ix.get(...)` directly) and -owns sinon (so `dispose()` restores stubs). - -Reference: [`test/message/message.test.ts`](../test/message/message.test.ts). - -```ts -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { createServices } from '#/_base/di/test'; -import type { TestInstantiationService } from '#/_base/di/test'; -import { registerRecordsServices } from '../records/stubs'; - -describe('XxxService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - - beforeEach(() => { - disposables = new DisposableStore(); - ix = createServices(disposables, { - base: [registerRecordsServices], - additionalServices: (reg) => { - // 1. Real collaborator, registered by interface. - reg.define(IContextService, ContextService); - // 2. System under test, registered by interface. - reg.define(IXxxService, XxxService); - }, - }); - }); - afterEach(() => disposables.dispose()); - - it('does the thing', () => { - // 3. Resolve by interface. - const svc = ix.get(IXxxService); - expect(svc.thing()).toBe('…'); - }); -}); -``` - -`createServices` builds the container from domain **service groups** plus -per-test overrides (see [Service groups](#service-groups)). Reach for -`ix.stub(...)` / `ix.set(...)` directly only inside an `it` when a single test -needs to swap a registration (for example, to inject a spy or a second -instance). Stubbing: - -- whole service, partial object: `ix.stub(IId, { method() { return … } })`; -- single method: `ix.stub(IId, 'method', value)` returns a sinon stub; - `ix.spy(IId, 'method')` returns a spy; -- a prebuilt instance or descriptor: `ix.set(IId, instance)` / - `ix.set(IId, new SyncDescriptor(Impl))`; -- when a collaborator's behavior must vary per test, model it as a - `Test*Service` subclass whose methods read suite-scoped `let` variables (the - `configurationValue` / `updateArgs` pattern) rather than rebuilding the - container each test. - -### Scope harness — `createScopedTestHost` - -Reach for this only when *which layer a service lives in* is itself the thing -being asserted, or when the SUT reads from parent/child scopes. It builds the -real `Scope` tree and resolves through it. - -Reference: -[`test/environment/environmentService.test.ts`](../test/environment/environmentService.test.ts). - -```ts -import { beforeEach, describe, expect, it } from 'vitest'; -import { LifecycleScope } from '#/app/scopes'; -import { - _clearScopedRegistryForTests, - registerScopedService, - ScopeActivation, -} from '#/_base/di/scope'; -import { createScopedTestHost, stubPair } from '#/_base/di/test'; - -describe('XxxService (scoped)', () => { - beforeEach(() => { - _clearScopedRegistryForTests(); - registerScopedService( - LifecycleScope.Agent, - IXxxService, - XxxService, - ScopeActivation.OnDemand, - 'xxx', - ); - }); - - it('resolves from the Agent scope with ancestor deps injected', () => { - const host = createScopedTestHost([stubPair(ILogService, stubLog())]); - const agent = host.child(LifecycleScope.Agent, 'main'); - const svc = agent.accessor.get(IXxxService); // by interface - expect(svc.thing()).toBe('…'); - host.dispose(); - }); -}); -``` - -Always `_clearScopedRegistryForTests()` and re-register explicitly in -`beforeEach`. Do not rely on a production module's top-level -`registerScopedService(...)` side-effect: import order then becomes part of the -test, and another suite's `_clearScopedRegistryForTests()` can wipe it. - -When a test intentionally replaces an existing static registration — swapping -one production implementation for a fake while keeping the rest of the registry -— use `overrideScopedService` (same signature). `registerScopedService` throws -on a duplicate (scope, id) pair, and `overrideScopedService` throws when nothing -is registered for the pair yet. Tool tests that re-register or restore agent -tool contributions use `overrideAgentToolService`, which replaces the scoped -registration and upserts the contribution-table entry. - -The scoped registration signature is -`registerScopedService(scope, id, ctor, activation = ScopeActivation.OnScopeCreated, domain?)`. -The fourth argument is activation and the fifth is domain. -`ScopeActivation.OnScopeCreated` is `0` and constructs the real instance during -scope creation; it is the default. `ScopeActivation.OnDemand` is `1` and -constructs the real instance on the first `get()`. - -## Register the SUT by interface - -Whichever harness you use, the SUT is registered under its interface -(`ix.set(IX, new SyncDescriptor(Impl))` or `registerScopedService(scope, IX, Impl, …)`) -and resolved by that interface. This is non-negotiable: it is the only thing -that keeps the production registration honest. - -A test that does `ix.createInstance(Impl)` is testing the class, not the -service. Convert those (see [Migration](#migrating-existing-tests)). - -## Shared stubs - -Hand-rolled stubs (`noopLog`, `noneEvent`, `unusedRecords`, …) must not be -copied between test files. Each domain that owns a frequently-stubbed -interface exports a stub from a `stubs.ts` **in the `test/` tree**, never from -`src/`: - -``` -test/log/stubs.ts → stubLog() / stubLogger() -test/turn/stubs.ts → stubTurn() -test/records/stubs.ts → stubAgentRecords() -test/environment/stubs.ts → stubEnvironment() -``` - -Reference: [`test/records/stubs.ts`](../test/records/stubs.ts). - -All test support lives under the `test/` tree so test-only code stays out of -the production source tree. Because `tsdown` builds from `src/index.ts`, -anything under `test/` is unreachable from the entry and is never bundled into -`dist/`. - -Conventions: - -- export a **factory** (`stubXxx()`), not a shared singleton, so tests cannot - leak state through a stub; -- name it `stub` — e.g. `stubAgentRecords`; -- the stub satisfies the full interface so the compiler, not a cast, guarantees - it stays in sync; -- import it with a **relative path** — `./stubs` from the same domain's tests, - `..//stubs` from another domain. Never import stubs from `#/…` (that - alias is for production `src/`) and never import one test file from another; -- a `stubs.ts` may import its domain's production types via `#//…`. - -If a stub is needed by two test files, it belongs in that domain's -`test//stubs.ts`. - -## Service groups - -Most unit tests stub the same handful of collaborators (`ILogService`, -`IAgentRecords`, `IConfigService`, `ITelemetryService`, …). Rather than repeat -`ix.stub(...)` lines in every `beforeEach`, each domain exports a -`register*Services` function from its `stubs.ts` that registers the default test -doubles for that domain: - -```ts -// test/log/stubs.ts -export function registerLogServices(reg: ServiceRegistration): void { - reg.defineInstance(ILogService, stubLog()); -} -``` - -`createServices(disposables, { base, additionalServices })` composes them: - -- `base` — an ordered list of service groups. Each group's registrations are - deduped (first writer wins), so groups supply safe defaults without - clobbering each other. -- `additionalServices` — applied after `base`. Registrations here **overwrite** - any base default, so a test can swap a stub for a spy, register the system - under test, or supply a one-off collaborator. - -```ts -ix = createServices(disposables, { - base: [registerLogServices, registerConfigServices, registerRecordsServices], - additionalServices: (reg) => { - reg.definePartialInstance(IAgentKaos, {}); // one-off collaborator - reg.define(IAgentRecords, spyRecords); // override a base default - reg.define(IXxxService, XxxService); // system under test - }, -}); -``` - -`ServiceRegistration` offers three verbs: - -- `define(id, Ctor)` — descriptor-backed registration; the real service is - instantiated on first resolve. Use for real collaborators and the system under test. -- `defineInstance(id, instance)` — a fully-built instance (a fake such as - `stubLog()`, or `new ConfigRegistry()`). -- `definePartialInstance(id, { ... })` — a partial mock; only the supplied - members are provided. Use for collaborators the test does not exercise. - -Conventions: - -- a group registers the domain's services **as dependencies** (a fake, or a `{}` - partial when no fake exists yet). When a service is the system under test, - the test registers the real implementation via `additionalServices` and does - not rely on the group's default for it; -- keep groups small and domain-local. A service that is almost always the - system under test, or that every consumer configures differently, should not - have a group — register it inline via `additionalServices`; -- import groups with a **relative path** (`..//stubs`), never from - `#/…`. - -`createServices` defaults to `strict: false` (missing dependencies warn rather -than throw), matching `new TestInstantiationService()`. Pass `strict: true` to -surface unregistered `@IService` dependencies. - -## Declaring dependencies - -Always use `@IService` constructor decorators — in fixtures and in production -services alike. - -```ts -// ✅ -class Consumer { - constructor(@IGreeter private readonly greeter: IGreeter) {} -} - -// ❌ no param() helper, no inline cast -class Consumer { - constructor(private readonly greeter: IGreeter) {} -} -param(IGreeter, Consumer, 0); -``` - -This holds for cycle tests too. Declare the loop with real constructor -dependencies (`ServiceLoop1(@IService2)` ↔ `ServiceLoop2(@IService1)`); do not -capture `accessor` inside a constructor and call `.get(peer)` to force an edge. - -Because the decorator runs when the class is defined, the `createDecorator` -identifier must be initialized **before** the class that uses it. Declare the -identifier, then the class: - -```ts -const IDep = createDecorator('dep'); -class Consumer { - constructor(@IDep private readonly dep: IDep) {} -} -``` - -For two services that depend on each other (a cycle), declare both identifiers -first, then both classes, so neither class references an uninitialized binding. - -Declare fixtures at module top, interface + decorator + implementation -co-located, and keep `_serviceBrand` on the interface when it represents a -real service — `GetLeadingNonServiceArgs` relies on the brand to tell service -parameters apart from static ones: - -```ts -const IGreeter = createDecorator('greeter'); -interface IGreeter { - readonly _serviceBrand: undefined; - greet(): string; -} -class Greeter implements IGreeter { - declare readonly _serviceBrand: undefined; - greet(): string { return 'hi'; } -} -``` - -Pure throwaway fixtures may omit `_serviceBrand`. - -## Lifecycle / teardown - -One `DisposableStore` per suite. Add the **container** and any event -subscriptions to it; dispose in `afterEach`. - -```ts -beforeEach(() => { disposables = new DisposableStore(); /* … */ }); -afterEach(() => disposables.dispose()); -``` - -Do **not** add the system-under-test itself to the store. -`TestInstantiationService` disposes every service it creates when the container -is disposed, so `ix.get(IX)` instances are cleaned up automatically via -`disposables.add(ix)`. Wrapping the SUT in `disposables.add(...)` would -double-dispose it. For the same reason, do not call `svc.dispose()` at the end -of a test unless you are asserting something about disposal itself. - -Scope-host tests call `host.dispose()` in `afterEach` (or at the end of the -`it`). Do not scatter bare `ix.dispose()` / `core.dispose()` calls through test -bodies — route teardown through the store so ordering is deterministic and -nothing leaks when a test fails mid-way. - -## Assertions and naming - -- One behavior per `it`; describe observable behavior - (`child shadows parent registration`), not implementation - (`calls _getOrCreateServiceInstance`). -- For cycles, assert `CyclicDependencyError` and its `path` array - (e.g. `['A', 'B', 'A']`), not merely `toThrow`. -- For disposal order, capture events in an array and assert the sequence - (`['C', 'B', 'A']` — children before parents). - -## Migrating existing tests - -Most legacy tests build the SUT with `ix.createInstance(Impl)`. Converting one -is mechanical: - -1. import the interface (`IX`) and the descriptor; -2. register the SUT by interface — `reg.define(IX, Impl)` inside - `additionalServices` (or `ix.set(IX, new SyncDescriptor(Impl))`); -3. replace `ix.createInstance(Impl)` with `ix.get(IX)`; -4. drop the `disposables.add(...)` wrapper around the SUT and any trailing - `svc.dispose()` — the container disposes it; -5. replace any hand-rolled collaborator object with the domain's shared stub - or service group (or add one to `test//stubs.ts` if it does not - exist); -6. delete now-unused imports. - -Before / after: - -```ts -// before -const svc = ix.createInstance(MessageService); - -// after — registration in beforeEach additionalServices -reg.define(IMessageService, MessageService); -// after — resolution in the test body -const svc = ix.get(IMessageService); -``` diff --git a/packages/agent-core-v2/docs/di.md b/packages/agent-core-v2/docs/di.md deleted file mode 100644 index 427e24daf07..00000000000 --- a/packages/agent-core-v2/docs/di.md +++ /dev/null @@ -1,427 +0,0 @@ -# DI(依赖注入)与 Scope — 场景化指南 - -> 本文按「给 agent-core-v2 加业务功能」会遇到的场景,从最简单到最复杂,逐个引入 DI 的概念。 -> 源码位于 [`src/_base/di/`](../src/_base/di/);测试约定见 [`docs/di-testing.md`](di-testing.md)。 - ---- - -## 0. 先把 DI 当成黑盒子 - -写业务代码时,你只需要向这个黑盒子声明三件事: - -- **我是谁** —— 一个能当 key 又能当类型的「身份」。 -- **我需要谁** —— 我的依赖由谁提供。 -- **我活多久** —— 我属于哪一层生命周期。 - -剩下的事(何时创建、是不是同一份、谁先谁后、何时销毁)都由容器负责。类只跟接口打交道,从不关心实现怎么 new。 - -下面每个场景只引入它所需要的那一块 DI。跟着场景走,概念会逐步叠加。 - ---- - -## 场景 1:加一个全局服务(不依赖任何人) - -> 你要做的:进程级只有一个、谁都能用的基础能力,比如日志、遥测。参考 [`log`](../src/log/log.ts)。 - -这一步引入四块:**接口 / 身份 / 实现 / 注册**。 - -### 1.1 写接口,带上 `_serviceBrand` - -```ts -// greet/greet.ts -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export interface IGreeter { - readonly _serviceBrand: undefined; // 类型记号:告诉 DI「这是一个服务」 - hello(): string; -} - -export const IGreeter: ServiceIdentifier = createDecorator('greeter'); -``` - -`createDecorator(name)` 造出的 `ServiceIdentifier` 一身二任:运行时是 key 和参数装饰器,编译时携带 `IGreeter` 类型。 - -> ⚠️ **约束:身份名字全局唯一。** `createDecorator` 按 `name` 缓存,同名返回同一个身份。两个域用了同一个字符串就会碰撞、共享一个身份。 - -### 1.2 写实现类 - -```ts -// greet/greetService.ts -import { LifecycleScope } from '#/app/scopes'; -import { registerScopedService, ScopeActivation } from '#/_base/di/scope'; -import { IGreeter } from './greet'; - -export class Greeter implements IGreeter { - declare readonly _serviceBrand: undefined; // 与接口的 _serviceBrand 对应 - hello(): string { return 'hi'; } -} -``` - -实现类用 `declare readonly _serviceBrand: undefined;` 对应接口上的类型记号。 - -### 1.3 注册到一层生命周期 - -```ts -// greet/greetService.ts(文件顶层,import 时执行) -registerScopedService( - LifecycleScope.App, // 活多久:进程级 - IGreeter, // 身份 - Greeter, // 实现 - ScopeActivation.OnScopeCreated, // 创建 App scope 时构造 - 'greet', // 域名(用于排错) -); -``` - -绑定在哪一层是这个类的**固有属性**,在注册点决定,不在调用点决定。 - -### 1.4 通过 barrel 导出,让注册生效 - -```ts -// greet/index.ts -export * from './greet'; -export * from './greetService'; // import 这一行即触发上面的 registerScopedService -``` - -再在包入口 [`src/index.ts`](../src/index.ts) 加一行: - -```ts -export * from './greet/index'; -``` - -于是「import 这个包」=「加载全部注册」。**没有中心装配文件**:绑定散落在各自域的实现文件里,靠 import 副作用收集。注册默认使用 `ScopeActivation.OnScopeCreated`,创建对应 Scope 时就构造真实实例;只有明确声明 `ScopeActivation.OnDemand` 的服务才推迟到首次 `get()`(见场景 5)。 - -至此,任何人都能 `accessor.get(IGreeter)` 拿到这个全局唯一的服务。 - ---- - -## 场景 2:你的服务要用别人的服务 - -> 你要做的:你的服务需要调用别的域的能力。参考 [`sessionMetadataService.ts`](../src/session/sessionMetadata/sessionMetadataService.ts)。 - -这一步引入:**构造器注入** 与 **按接口解析**。 - -### 2.1 用 `@IX` 在构造器上声明依赖 - -```ts -export class SessionMetadata extends Disposable implements ISessionMetadata { - declare readonly _serviceBrand: undefined; - - constructor( - @ISessionContext private readonly ctx: ISessionContext, - @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, - @ILogService private readonly log: ILogService, - ) { - super(); - } -} -``` - -`@ISessionContext` 只做一件事:把「第 0 个参数需要 `ISessionContext`」记到类的元数据上。容器 new 这个类时读元数据,把依赖填好。 - -### 2.2 三条不可破的约束 - -1. **不要 `new` 带 `@IService` 依赖的类。** `new` 会绕过容器:绕过注册、绕过 scope、绕过单例缓存。要用就 `@IX` 注入,或 `accessor.get(IX)`。 -2. **`@IX` 只能装饰构造器参数。** 装饰到字段/方法上会在运行时抛错。 -3. **服务参数排在静态参数之后**(静态参数见场景 7)。 - -### 2.3 消费方按接口取,看不到实现 - -```ts -const meta = accessor.get(ISessionMetadata); // 类型是 ISessionMetadata -``` - -消费方只 import **接口** 和 **`IX` 身份**,从不 import 实现类。这是 DI 把「接口 → 实现」的替换权完全握在容器手里的关键。 - -> 如果你需要的不是「一个服务」而是「一份配置」,通常做法是把它也做成一个服务注入进来(如 `IConfigService`);如果是「每轮一个、带参数的非单例对象」,见场景 7。 - ---- - -## 场景 3:你的服务不是全局一份 - -> 你要做的:每个会话一份、或每个 agent 一份。参考 [`sessionMetadata`](../src/session/sessionMetadata/sessionMetadata.ts)、[`turn`](../src/turn/turn.ts)。 - -这一步引入:**`LifecycleScope` 四层生命周期** 与 **父子 scope 的可见性**。 - -### 3.1 四层,按寿命从长到短 - -```ts -// src/app/scopes.ts(业务层声明;内核只认识字符串 kind 与拓扑序) -export enum LifecycleScope { - App = 'app', // 进程级,全局一份 - Workspace = 'workspace', // 一个工作区 handler(与 Session 一对多) - Session = 'session', // 一次会话 - Agent = 'agent', // 一个 agent -} -``` - -拓扑里越靠后,寿命越短、越靠叶子。注册时把 `scope` 换成对应层即可: - -```ts -registerScopedService( - LifecycleScope.Session, - ISessionMetadata, - SessionMetadata, - ScopeActivation.OnDemand, - 'sessionMetadata', -); -``` - -「单例」的粒度是**每个 scope 一份**:App 的 `ILogService` 全局只有一份;每个 Session scope 各有自己的 `ISessionMetadata`。 - -### 3.2 子 scope 看得见父 scope,反之不行 - -Scope 是一棵树,`kind` 必须沿父子方向**严格递增**: - -``` -App (0) - └── Workspace (1) - └── Session (2) - └── Agent (3) -``` - -解析服务时,容器先看自己这一层,没有就**递归问父 scope**。所以一条铁律: - -> **短寿命的服务可以注入长寿命的服务,反过来不行。** - -- ✅ Agent 服务注入 Session / Workspace / App 服务(往上找,找得到)。 -- ❌ App 服务注入 Session 服务(App 创建时 Session 还不存在,且父不会往下找)。 - -这条规则由树的结构强制保证,不靠纪律维持。 - ---- - -## 场景 4:你的服务要释放资源 - -> 你要做的:服务里订阅了事件、开了定时器、持有了句柄,scope 销毁时要释放。参考 `FlagService`([`flagService.ts`](../src/app/flag/flagService.ts))。 - -这一步引入:**`Disposable` / `IDisposable` 生命周期**。 - -```ts -import { Disposable } from '#/_base/di/lifecycle'; - -export class FlagService extends Disposable implements IFlagService { - declare readonly _serviceBrand: undefined; - - constructor(@IConfigService private readonly config: IConfigService) { - super(); - this._register( - this.config.onDidChangeConfiguration(() => { /* … */ }), // 收集子资源 - ); - } -} -``` - -- 继承 `Disposable`,用 `this._register(d)` 收集任何 `IDisposable`(事件订阅、`toDisposable(fn)` 等)。 -- 容器在销毁这个服务时会自动调它的 `dispose()`,它注册过的子资源随之释放。 - -销毁顺序是确定的(见场景 3 的树):**子 scope 先死,同 scope 内按构造逆序释放**(后 new 的先释放)。业务代码只声明「我活在哪一层」,从不手动释放。 - ---- - -## 场景 5:选择服务的构造时机 - -> 你要做的:决定服务随 Scope 创建,还是等到第一次被请求时再创建。 - -这一步引入唯一的构造时机选项:**`ScopeActivation`**。 - -```ts -export enum ScopeActivation { - OnScopeCreated = 0, - OnDemand = 1, -} -``` - -```ts -// 默认:创建 App scope 时构造真实实例 -registerScopedService( - LifecycleScope.App, - ILogService, - LogService, - ScopeActivation.OnScopeCreated, - 'log', -); - -// 按需:首次 get(IScopeRegistry) 时构造真实实例 -registerScopedService( - LifecycleScope.App, - IScopeRegistry, - ScopeRegistry, - ScopeActivation.OnDemand, - 'gateway', -); -``` - -`ScopeActivation.OnScopeCreated` 是第四个参数的默认值。创建 Scope 时,容器会构造采用此模式的全部服务,并先构造它们的依赖。任何一个构造器失败,整个 Scope 创建失败。普通服务以及必须在 Scope ready 时生效的构造器副作用都使用此模式。 - -`ScopeActivation.OnDemand` 只保存描述符,不会在 Scope 创建时构造服务。第一次 `get()` 会直接构造并缓存真实实例,后续 `get()` 返回同一实例。只有确实需要等到服务被请求时才执行构造器,才使用此模式。 - -两种模式共用同一套依赖图,循环依赖都会抛 `CyclicDependencyError`。 - -完整签名是 `registerScopedService(scope, id, ctor, activation = ScopeActivation.OnScopeCreated, domain?)`:第四个参数是 activation,第五个参数是 domain。 - ---- - -## 场景 6:在普通函数里临时用服务 - -> 你要做的:你不想写一个新类,只是在一个函数里临时拿一个服务用一下。或你要给外部提供一个 `ServicesAccessor`。参考 [`gatewayService.ts`](../src/gateway/gatewayService.ts)。 - -这一步引入:**`IInstantiationService.invokeFunction`** 与 **`ServicesAccessor`**。 - -```ts -const accessor: ServicesAccessor = { - get: (id: ServiceIdentifier): T => instantiation.invokeFunction((a) => a.get(id)), -}; -``` - -`invokeFunction(fn)` 会给 `fn` 一个**只在这次调用期间有效**的 `ServicesAccessor`。 - -> ⚠️ **约束:accessor 只在调用期间有效。** `invokeFunction` 返回后再 `accessor.get()` 会抛 `"service accessor is only valid during the invocation"`。不要把 accessor 存起来异步用——要长期持有服务,就在构造器里注入(场景 2)。 - ---- - -## 场景 7:创建带依赖、但不是单例的对象 - -> 你要做的:每轮对话都要 new 一个新对象,但它也有 `@IService` 依赖。比如一个 per-turn 的执行器。 - -这一步引入:**`IInstantiationService.createInstance`** 与 **静态参数**。 - -```ts -class TurnRunner { - constructor( - private readonly input: string, // 静态参数:调用时传 - private readonly turn: number, // 静态参数:调用时传 - @ILogService private readonly log: ILogService, // 服务参数:容器注入 - ) {} -} - -// 调用时:静态参数你传,服务参数容器填 -const runner = instantiation.createInstance(TurnRunner, 'hello', 1); -``` - -容器把静态参数放前面、服务参数接在后面,再 `Reflect.construct` 出实例。这个对象**不会**被放进任何 scope 的单例缓存——每次都是新实例。 - -> 这就是「服务参数必须排在静态参数之后」的原因:容器按 `@IX` 记录的参数位置排序后依次注入。`_serviceBrand` 让编译器能在类型上区分这两类参数。 - ---- - -## 场景 8:你的服务要派生子容器 / 子 scope - -> 你要做的:你的服务负责「拉起一个新会话 / 新 agent」,需要为它造一个子 scope。参考 `ScopeRegistry`([`gatewayService.ts`](../src/gateway/gatewayService.ts))。 - -这一步引入:**注入 `IInstantiationService` 本身** 与 **`createChild`**。 - -每个容器都把自己绑定成 `IInstantiationService`,所以你可以像注入别的服务一样注入它: - -```ts -export class ScopeRegistry implements IScopeRegistry { - declare readonly _serviceBrand: undefined; - - constructor(@IInstantiationService private readonly instantiation: IInstantiationService) {} - - createSession(opts: CreateSessionOptions): Promise { - const collection = new ServiceCollection(); - for (const entry of getScopedServiceDescriptors(LifecycleScope.Session)) { - collection.set(entry.id, entry.descriptor); // 收集 Session 这一层的描述符 - } - const child = this.instantiation.createChild(collection); // 派生子容器 - const accessor: ServicesAccessor = { - get: (id: ServiceIdentifier): T => child.invokeFunction((a) => a.get(id)), - }; - const handle: IScopeHandle = { id: opts.sessionId, kind: LifecycleScope.Session, accessor }; - this.sessions.set(opts.sessionId, handle); - return Promise.resolve(handle); - } -} -``` - -关键点: - -- `getScopedServiceDescriptors(scope)` 能拿回注册在某一层的所有描述符,装进一个 `ServiceCollection`。 -- `instantiation.createChild(collection)` 造一个子容器,它的父指针指向当前容器——于是子容器能向上解析到 App 的服务(场景 3 的可见性规则)。 -- 给外部暴露时,用 `invokeFunction` 把子容器包成 `ServicesAccessor`(场景 6)。 - -> 更高层通常直接用 [`Scope.createChild(kind, id)`](../src/_base/di/scope.ts)(它帮你做了「筛描述符 + 建子容器 + 构造 `OnScopeCreated` 服务」);只有需要手动控制 `ServiceCollection` 时才像上面这样写——手动 `createChild` 不会执行 Scope 激活,服务都要由消费者解析。 - ---- - -## 场景 9:撞上循环依赖(不允许,要重构) - -> 业务规则:**不允许循环依赖。** 容器会拒绝它;撞上时的正确处理是重构,不是让它跑通。 - -### 9.1 容器会拒绝同步成环 - -A 创建中要 B,B 创建中又要 A——容器会抛 `CyclicDependencyError`,`path` 形如 `['A', 'B', 'A']`。自环(A 依赖自己)同样会被拒绝。这不是 bug,是保护机制:它在告诉你「这两个服务的职责划错了」。 - -### 9.2 为什么不允许 - -- scope 分层让正常依赖天然是 DAG(Agent → Session → Workspace → App 向上找),一个环几乎总是设计味道。 -- 靠「让环刚好能跑」会把构造顺序变成隐式约定,难调试、难排错。 - -所以 v2 的立场是:**依赖图必须是无环的。** - -### 9.3 撞上时怎么重构 - -按优先级考虑: - -1. **抽出第三个服务 C。** 把 A、B 互相需要的那部分逻辑提到 C,让 A、B 都依赖 C,而不是互相依赖。这是最常见的解。 -2. **用事件解耦。** 如果 A 只是想知道 B 的某个变化,让 B 通过 `IEventService` 发事件、A 订阅,而不是 A 直接持有 B 的引用。 -3. **重新划分 scope。** 也许其中一个本不该在这一层——它其实该更短或更长寿命,移动后环自然消失。 - -### 9.4 激活方式不能破环 - -`ScopeActivation.OnScopeCreated` 和 `ScopeActivation.OnDemand` 都通过同一套同步依赖图构造服务。改变激活方式不能让循环依赖变得合法。撞上 `CyclicDependencyError` 时,按 9.3 重构。 - ---- - -## 场景 10:给服务写测试 - -> 你要做的:让测试走和生产一样的路径——按接口解析、依赖由容器注入。 - -这一步引入:**两个测试 harness**。详见 [`docs/di-testing.md`](di-testing.md),这里只给选择标准: - -| 测什么 | 用哪个 harness | 怎么取 SUT | -|---|---|---| -| 单个服务的行为(单元) | `TestInstantiationService`(扁平容器) | `ix.set(ISut, new SyncDescriptor(Sut))` 后 `ix.get(ISut)` | -| 跨 scope 接线 / 服务活在哪一层 | `createScopedTestHost`(scope 树) | `host..accessor.get(ISut)` | - -核心规则:**按接口解析被测对象,绝不 `new` 带 `@IService` 依赖的实现类**——否则 `registerScopedService(IX → Impl)` 这条绑定在测试里根本没跑过。 - ---- - -## 附录 A:接口速查 - -| 接口 | 出现场景 | 作用 | -|---|---|---| -| `createDecorator(name)` → `ServiceIdentifier` | 1 | 造身份(运行时 key + 编译时类型 + 参数装饰器) | -| `@IService` | 2, 7 | 在构造器参数上声明依赖 | -| `registerScopedService(scope, id, ctor, activation, domain)` | 1, 3, 5 | 把实现绑定到一层生命周期和构造时机 | -| `ServicesAccessor.get(IX)` | 2, 6 | 按接口解析实例 | -| `IInstantiationService.invokeFunction(fn, …)` | 6, 8 | 在函数里临时拿到 accessor | -| `IInstantiationService.createInstance(ctor, …args)` | 7 | 创建非单例对象并注入依赖 | -| `IInstantiationService.createChild(collection)` | 8 | 派生子容器 | -| `getScopedServiceDescriptors(scope)` | 8 | 取回注册在某一层的所有描述符 | -| `Disposable` / `DisposableStore` / `IDisposable` | 4 | 资源管理与销毁 | -| `Scope` / `LifecycleScope` | 3, 8 | 生命周期树 | -| `ScopeActivation` | 3, 5 | 选择随 Scope 创建或首次 `get()` 时构造 | -| `SyncDescriptor` | (测试/底层) | 把「构造器 + 静态参数」打包成待 new 描述符 | - -> 遗留导出(v2 不用,知道即可):`refineServiceDecorator` 是 VS Code 遗留的 DI 工具,v2 的 src/test 零引用,统一走 `registerScopedService`。 - -## 附录 B:红线汇总 - -1. 不 `new` 带 `@IService` 依赖的类——用 `@IX` 注入或 `accessor.get(IX)`。 -2. `@IX` 只能装饰构造器参数;服务参数排在静态参数之后。 -3. 接口和实现都带 `_serviceBrand`。 -4. 身份名字全局唯一。 -5. 父 scope 的服务不依赖子 scope 的服务(运行时也解析不到)。 -6. **不写循环依赖**——容器会抛 `CyclicDependencyError`;撞上时按场景 9 重构,激活方式不能绕过循环检测。 -7. `ServicesAccessor` 只在 `invokeFunction` 调用期间有效,不存起来异步用。 -8. 注册写在实现文件顶层;同一 (scope, token) 只能静态注册一次——重复注册(包括经别名的同一 decorator 对象)在 import 期抛 `BugIndicatingError`,有意替换用 `overrideScopedService`(目标没有注册时同样抛错)。测试里用 `_clearScopedRegistryForTests()` 后显式重注册,不依赖生产 import 顺序。 - -## 附录 C:新增一个服务的标准动作 - -1. **契约**:`src//.ts` 写接口(带 `_serviceBrand`)+ `createDecorator` 身份。 -2. **实现**:`src//Service.ts` 写类,`@IX` 声明依赖,文件顶层 `registerScopedService(scope, IX, Impl, activation, '')`;第四个参数是 activation,第五个参数是 domain。 -3. **barrel**:`src//index.ts` re-export 契约和实现。 -4. **入口**:`src/index.ts` 加一行 `export * from './/index';`。 -5. **测试**:`test//` 用 `TestInstantiationService` 或 `createScopedTestHost`,按接口解析。 diff --git a/packages/agent-core-v2/docs/errors.md b/packages/agent-core-v2/docs/errors.md deleted file mode 100644 index 5903c2a3208..00000000000 --- a/packages/agent-core-v2/docs/errors.md +++ /dev/null @@ -1,88 +0,0 @@ -# errors - -> Error infrastructure for agent-core-v2: base classes, the per-domain code -> contract, the public `ErrorCodes` facade, wire serialization, and the -> conventions domains follow when raising errors. - -Base classes and serialization are centralized in `_base/errors`; error **codes** -are **decentralized** — each domain owns an `errors.ts` that contributes its -codes and metadata, and the `src/errors.ts` facade aggregates them into the -unified `ErrorCodes` const. - -## Where things live - -- `src/_base/errors/errors.ts`: base classes — `Error2`, `ExpectedError`, `ErrorNoTelemetry`, `BugIndicatingError`, `NotImplementedError`, plus the `isError2` guard and `unwrapErrorCause`. -- `src/_base/errors/codes.ts`: the `ErrorDomain` contract, the `ErrorCode` type (aliased to the protocol's `KimiErrorCode`), the runtime registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and the domain-independent `CoreErrors` (`internal`, `not_implemented`). -- `src/_base/errors/serialize.ts`: `ErrorPayload`, `isCodedError`, `toErrorPayload`, `fromErrorPayload`, `makeErrorPayload`. Reads retryability from the registry via `errorInfo`. The wire-facing names (`KimiErrorPayload`, `toKimiErrorPayload`) mirror the protocol contract and keep their names even though the in-process class is `Error2`. -- `src/_base/errors/errorMessage.ts`: `toErrorMessage(error, verbose?)` for logs/CLI. -- `src/_base/errors/unexpectedError.ts`: `onUnexpectedError` / `setUnexpectedErrorHandler` / `safelyCallListener`. -- `src//errors.ts`: each domain's `XxxErrors` descriptor (codes + retryable list + per-code info overrides), self-registered on import. -- `src/errors.ts`: the **facade** — imports every domain's `errors.ts` (triggering registration), builds the unified `ErrorCodes` const, and re-exports all error primitives. This is the import throw sites use. - -## Conventions (hard rules) - -- **Throw a coded error, not a bare string.** `throw new Error2(ErrorCodes.X, …)`. `throw new Error('x')` only for unreachable guards; `BugIndicatingError` when the throw site indicates a caller bug (e.g. reading a service before its `ready`); `NotImplementedError('feature')` for stubs. -- **Every domain codes ALL of its failure modes.** This includes errors raised on tool-execution paths whose message is fed back to the model (tool-input validation is a domain failure mode too) — whether a given scope (App / Workspace / Session / Agent) or the model ever sees an error is decided by event-filtered subscriptions, never by the error's type. The uncoded errors left are: `_base` infrastructure errors (DI, event, lifecycle, text, execEnv — deliberately left as plain guards / classes for now), control-flow sentinels that never leave their domain (`UserCancellationError`, `TaskCancelledError`, `TransientCloudError`, `GrepAbortedError`, `ProcessExitError`, `CompactionTruncatedError`), `CyclicDependencyError` (a documented DI wiring protection), and `PathSecurityError` (tool-path validation with its own `PathSecurityCode` taxonomy). The `ChatProviderError` L0 taxonomy is born-coded: every class extends `Error2` and computes its wire code at construction (`kosong/contract/errors.ts`), so `translateProviderError` is only the abort guard plus the foreign-error fallback. -- **Define codes in the owning domain.** A domain's codes live in `/errors.ts` next to its interfaces, exported as an `XxxErrors` descriptor — never in `_base/errors`. -- **One `code` per failure mode.** Codes read `domain.reason` (e.g. `tool.unknown_tool`). The set of valid code strings is fixed by the protocol (`KimiErrorCode`); adding a brand-new code means updating the protocol first. Renaming/removing a code is a major (breaks SDK clients). -- **Import from the facade.** Throw sites and cross-domain consumers do `import { ErrorCodes, Error2 } from '#/errors'`. A domain's own `errors.ts` references its own descriptor (`LoopErrors.codes.X`) and imports only from `#/_base/errors` (never from `#/errors`, to avoid cycles). -- **Translate foreign errors at the boundary.** Provider/HTTP, fs, MCP errors are caught at the domain boundary and re-thrown as the domain's coded error. `_base/errors` never imports a business domain. -- **Translation is idempotent.** A translator (`toHostFsError`, `toStorageIoError`, …) returns its input unchanged when it is already the domain's error type, so layered boundaries never double-wrap. The original error always goes to `cause`. -- **`details` is structured and JSON-serializable; `message` is a short human sentence.** Paths, errnos, syscalls, scope/key, line numbers go into `details`; the message must stay readable without them. -- **Cancellation passes through untranslated.** A translation boundary that can see a cancellation-class error (`UserCancellationError` from `_base/utils/abort`) rethrows it as-is. fs/process translation never encounters cancellation, so those translators do not check for it — apply the rule only at boundaries that actually can. -- **Classify wrapped foreign errors via `unwrapErrorCause`.** Predicates that branch on raw shapes (errno, provider status) test `unwrapErrorCause(error)`, since boundary-translated errors carry the raw error as `cause`. -- **Branch on `code`, never `instanceof`, across the wire.** Class identity does not survive serialization. In-process, `instanceof Error2` / `isCodedError` are fine. - -## Adding a domain error (recipe) - -In `/errors.ts`: - -```ts -import { registerErrorDomain, type ErrorDomain } from '#/_base/errors'; - -export const ToolErrors = { - codes: { - UNKNOWN_TOOL: 'tool.unknown_tool', - EXECUTION_FAILED: 'tool.execution_failed', - }, - retryable: ['tool.execution_failed'], - info: { - 'tool.unknown_tool': { - title: 'Unknown tool', - retryable: false, - public: true, - action: 'Check the tool name passed by the model.', - }, - }, -} as const satisfies ErrorDomain; - -registerErrorDomain(ToolErrors); -``` - -Then wire it into the facade in `src/errors.ts`: import `ToolErrors`, add -`...ToolErrors.codes` to the `ErrorCodes` spread, and re-export it. The -`satisfies ErrorDomain` guarantees every code value is a protocol-known -`ErrorCode`, and `registerErrorDomain` makes its metadata available to -serialization. - -## Domain tiers in practice - -The os / persistence / wire domains show the standard shapes: - -- **`os.fs` (`HostFsError`, `os/interface/hostFsErrors.ts`)** — every `IHostFileSystem` backend translates raw errnos at its boundary via the pure `toHostFsError(err, { path, op })`: `ENOENT→os.fs.not_found`, `EISDIR→os.fs.is_directory`, `ENOTDIR→os.fs.not_directory`, `EEXIST→os.fs.already_exists`, `EACCES/EPERM→os.fs.permission_denied`, `ENOTEMPTY→os.fs.not_empty`, everything else `os.fs.unknown`. `details` carries `{ path, op, errno?, syscall? }`. Documented boolean semantics (e.g. `createExclusive` returning `false` on `EEXIST`) stay booleans, not errors. -- **`os.process` (`HostProcessError`, `os/interface/hostProcess.ts`)** — `os.process.spawn_failed` (details `{ command, args?, cwd?, errno? }`) and `os.process.kill_failed`; both carry the raw error as `cause`. Kill keeps its deliberate tolerances: `ESRCH` is a silent no-op, `EPERM` degrades to `child.kill()`. -- **`storage` (`StorageError`, `persistence/interface/storage.ts`)** — `storage.not_found` / `decode_failed` / `corrupted` / `io_failed` / `locked` / `permission_denied` / `disk_full`. ENOENT keeps its established absence semantics (`read → undefined`, `list → []`) and is *not* an error; other I/O failures are mapped by errno at the backend boundary via `toStorageIoError`: `EACCES/EPERM→storage.permission_denied`, `ENOSPC→storage.disk_full`, an unexpected `ENOENT→storage.not_found`, everything else `storage.io_failed` (the only retryable one besides `storage.locked`). Codec parse failures become `storage.decode_failed` with `{ scope, key, format }`; append-log corruption is `AppendLogCorruptedError` (`storage.corrupted`). `storage.locked` is reserved for a store exclusively held by another process — consumers (e.g. `FileSessionIndex`) catch it explicitly and fall back to their non-read-model path with a one-time warning; there is no silent no-op degradation. (The minidb query-store backend is a multi-process `ClusterDb` and no longer throws it: peers share the store, and per-shard lock contention surfaces as a transient `LockError` instead.) -- **`wire` (`WireError`, `wire/errors.ts`)** — `wire.unknown_record`: restore skips records whose durable event type is absent from the folded registry (compatibility) and reports each skip through `onUnexpectedError`; `wire.migration_missing` covers journals that predate the migration chain. The sibling `event`/`state` domains own `event.duplicate_event` (a build-time bug), `state.duplicate_fold`, `state.durability_mismatch`, and `CycleError` (`state.cycle`, details carry the drain depth and a capped event-type sample). - -## Serialization & boundary translation - -- `toErrorPayload(error)`: any coded error (incl. deserialized shapes) → its code + `retryable` from `errorInfo`; anything else → `internal`. -- `fromErrorPayload(payload)`: rehydrates an `Error2` for in-process `instanceof` / `isCodedError` use at the SDK/RPC boundary. -- `isCodedError(error)`: structural guard (checks `code` against the registry), so it works for both `Error2` instances and plain objects revived from a payload. -- The registry is populated when the facade is imported (the package `index.ts` re-exports it); tests that import a single domain get that domain's codes via its self-registration. `errorInfo` falls back to `{ title: code, retryable, public: true }` for any unregistered code. - -## References - -- `packages/agent-core-v2/src/_base/errors/` — contract, registry, base classes, serialization. -- `packages/agent-core-v2/src/errors.ts` — the aggregating facade. -- `packages/protocol/src/events.ts` — the canonical `KimiErrorCode` wire union. diff --git a/packages/agent-core-v2/docs/features.md b/packages/agent-core-v2/docs/features.md deleted file mode 100644 index 4f4c750c528..00000000000 --- a/packages/agent-core-v2/docs/features.md +++ /dev/null @@ -1,110 +0,0 @@ -# Features — self-contained built-in capabilities - -A **Feature** is a built-in capability (plan mode, and later mcp, …) authored as ONE -self-contained unit under `src/features//`. The Feature unit is the single place -that declares everything the capability contributes to the engine; retracting the unit -withdraws all of it across the scope tree (连坐). - -`plan` is the reference implementation: `src/features/plan/` (extracted from -`agent/plan` + `agent/tools/plan`). - -## The base class - -```ts -import { Feature } from '#/features/feature'; -import { registerFeature } from '#/features/featureRegistry'; - -export class PlanFeature extends Feature { - static override readonly name = 'plan'; // stable unit name (the assembly keys by it) - - constructor() { - super(); - this.contributeAgentService(IAgentPlanService, AgentPlanService); - this.contributeTool(IEnterPlanModeTool, EnterPlanModeTool, { name: 'EnterPlanMode', domain: 'plan' }); - this.contributeTool(IExitPlanModeTool, ExitPlanModeTool, { name: 'ExitPlanMode', domain: 'plan' }); - this.onDispose(() => { /* cleanup */ }); - } -} - -registerFeature(PlanFeature); // import = register -``` - -`Feature extends Service`, so every contribution runs through the normal two-phase -construction protocol (declare contributions in the constructor; they are buffered and -flushed by the kernel). A feature may also declare `static readonly meta = { ... }` — -free-form self-description that `IFeatureManager.units()` introspection carries (and -kap-server surfaces via `GET /api/v1/meta`); it defaults to `{}`. The helpers are thin -compositions over the existing seams: - -| Helper | Composition | Semantics | -|---|---|---| -| `contribute(token, value)` | `this.provide(token, value)` | raw collection record | -| `contributeService(scope, id, ctor, opts?)` | `ScopeUnits(scope)` function recipe | one live unit per present AND future scope of that kind; retracted everywhere when the feature dies | -| `contributeAgentService(id, ctor, opts?)` | `contributeService(LifecycleScope.Agent, …)` | the common case — Agent-granular domains (todo/goal/cron/interaction/reminder/dateChange/skill) ship as Agent-scope DI services (DI token + interface + implementation) through this seam; eager by default | -| `contributeTool(id, ctor, options)` | per-agent `OnDemand` registration + `AgentToolContribution` record | the tool ctor keeps full `@IXxx` DI; the activation fold filters by name before constructing | -| `contributeProfiles(profiles, opts?)` | `AgentProfileContribution` record | `sourceId` defaults to `feature:` | -| `contributeConfig(domain, schema, options?)` | `ConfigSectionContribution` record | see the static-channel rule below before using | -| `contributeCommand({ name, description?, run })` | `CommandContribution` record | runs engine-side; `ctx.get(id)` resolves through the agent container and is valid only during the synchronous part of `run` (resolve up front, then `await`) | -| `onDispose(fn)` | `this._register(toDisposable(fn))` | cleanup on retraction | - -## Assembly lifecycle - -1. A feature module calls `registerFeature(Recipe)` at its top level - (`src/features/featureRegistry.ts` holds the module table). -2. `src/index.ts` imports the feature leaf (`import '#/features/plan/planFeature';`), - so importing the package registers it. -3. At App-scope creation the `IFeatureAssemblyService` - (`src/features/featureAssemblyService.ts`) drains the table and assembles each - recipe through `IFeatureManager.provideUnit` — the same provide path as static - scope batches. Every feature is named, introspectable (`IFeatureManager.units()`, - visible in the kimi-inspect DI view), and individually retractable - (`unprovideUnit(name)` / `updateUnit(name, config)`). -4. Per-scope materialization goes through the kernel's `ScopeUnits` fold: a service a - feature contributes at Agent scope appears in every existing and future Agent scope, - bound by the same cascade rules as a static registration. - -## Static channels vs Feature channels (the rule for built-in features) - -Some contribution kinds must stay on the **static import=register channels** even when -they belong to a feature: - -- **Config sections** (`registerConfigSection`) — the config manifest generator - (`scripts/gen-config-manifest.mts`) drains the module-level table and statically scans - for call sites; a runtime-only contribution would vanish from - `docs/config-manifest.toml`. -- **Agent profiles** contributed via `registerAgentProfile` — same static-table - reasoning. -- **Wire vocabulary** (durable `Event2` classes / - `defineState(...).replayable(...)`) — wire records must remain replayable even if the - feature unit is retracted. - -The Feature unit carries the **runtime capabilities**: services, tools, commands, hook -subscriptions. `PlanFeature` is the example: `configSection.ts` and `profile/plan.ts` -keep their static registrations; the service and the two tools go through the Feature. - -## Events and hooks inside a feature - -- Agent-scope services a feature contributes can use the string form of the unit `on` - capability — `this.on('turn.ended', …)` — backed by the production `FiberEventResolver` - (`src/app/event/fiberEventResolver.ts`), which resolves the event against the scope's - `IEventBus` (attaching lazily if the bus is not materialized yet). Constructor - injection of `@IEventBus` + `subscribe` remains the fully explicit equivalent. -- Tool-call guards (e.g. the plan-mode write veto) subscribe to - `IAgentToolExecutorService.onBeforeExecuteTool` inside the contributed Agent-scope - service — see `src/features/plan/planService.ts` for the canonical veto-listener - pattern. - -## Adding a new feature - -1. `src/features//` — domain files follow the usual conventions (no comments, - one service per file pair, `.md?raw` assets move with the feature). -2. `Feature.ts` — the Feature subclass + `registerFeature(...)`. -3. `src/index.ts` — precise leaf imports/exports; no barrel. -4. Tests in `test/features//`; for the assembly mechanics mirror - `test/features/feature.test.ts` (scoped host, `registerFeature` before - `createScopedTestHost`). -5. If the feature registers agent-state keys, `scripts/gen-state-manifest.mts` resolves - the scope of `.register(key)` call sites under `src/features/**` from the receiver's - `I{App,Workspace,Session,Agent}StateService` type — register through a member typed - as the scope's state service. Regenerate the manifests - (`pnpm gen:config-manifest && pnpm gen:wire-manifest && pnpm gen:state-manifest`). diff --git a/packages/agent-core-v2/docs/flag.md b/packages/agent-core-v2/docs/flag.md deleted file mode 100644 index 62201081265..00000000000 --- a/packages/agent-core-v2/docs/flag.md +++ /dev/null @@ -1,110 +0,0 @@ -# flag - -> Experimental feature-flag gating for agent-core-v2 — a App-scope `IFlagService` resolver plus a writable `IFlagRegistry` catalog that domains contribute their flags to, backed by the `[experimental]` config section. - -Gates not-yet-public features behind `IFlagService.enabled(id)`, per the repository hard rule that unreleased behavior must be flag-gated. Ported from `packages/agent-core/src/flags/**`; v1 was a process-global `FlagResolver` singleton over a central `FLAG_DEFINITIONS` array, v2 is a scoped DI service whose flag definitions are registered **decentrally** by each owning domain — there is no central catalog to edit. - -## Layout - -- `src/flag/flagRegistry.ts` — `IFlagRegistry` token + `FlagDefinitionInput` / `FlagId` / `FlagSurface` types + `registerFlagDefinition` / `getContributedFlags` (import-time contribution queue). -- `src/flag/flagRegistryService.ts` — `FlagRegistryService` impl; in-memory catalog seeded from import-time contributions; App scope. -- `src/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod). -- `src/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope. -- `src/flag/index.ts` — barrel; re-exported by `src/index.ts`. -- `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. - -## Public surface - -- `IFlagService` (DI token, App scope): `enabled(id)`, `explain(id)`, `snapshot()`, `enabledIds()`, `explainAll()`, `setConfigOverrides(overrides)`, `registry`. -- `IFlagRegistry` (DI token, App scope): `register(definition)`, `get(id)`, `list()` — writable catalog. `register` is the **runtime** path (tests, dynamic registration); `IFlagService.registry` exposes the same instance for hosts/UI to enumerate flags without resolving them. -- `registerFlagDefinition(definition)` — the **import-time** path. Domains call this from their `flag.ts` top level; contributions are queued and drained by `FlagRegistryService` when it is instantiated. -- `FlagService` / `FlagRegistryService`: exported for tests and hosts that construct them directly. - -## Resolution precedence - -Highest wins; env is read live on every call (nothing cached): - -1. L1 per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MY_FEATURE`) → forces on/off. -2. L2 `[experimental]` config section per-flag override. -3. L3 master env `KIMI_CODE_EXPERIMENTAL_FLAG` truthy → every flag on. -4. L4 registry `default`. - -`explain(id)` returns the winning `source` (`master-env` | `env` | `config` | `default`) plus the effective `configValue`. `explain(id)` returns `undefined` (and `enabled(id)` returns `false`) for an id that no domain has registered. - -## Config integration - -- `FlagService` registers the `[experimental]` section into `IConfigRegistry` at construction (`registerSection('experimental', ExperimentalConfigSchema)`) and reads overrides from `IConfigService`. -- It subscribes `IConfigService.onDidChangeConfiguration` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live. -- `IConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by `FlagService`. -- `setConfigOverrides(overrides)` is an imperative escape hatch for tests and hosts without an `IConfigService`; hosts on `IConfigService` should set the `[experimental]` section instead. - -Config shape mirrors v1: - -```toml -[experimental] -my_feature = false -``` - -Keys are intentionally loose (`z.record(z.string(), z.boolean())`), so obsolete flags stay inert config. - -## Add a flag - -Declare the definition in the owning domain's `flag.ts` and call `registerFlagDefinition` at the module top level. There is no central catalog to edit. - -`src//flag.ts`: - -```ts -import { type FlagDefinitionInput, registerFlagDefinition } from '#/flag'; - -export const myFeatureFlag: FlagDefinitionInput = { - id: 'my_feature', - title: 'My feature', - description: '...', - env: 'KIMI_CODE_EXPERIMENTAL_MY_FEATURE', - default: false, - surface: 'both', -}; - -registerFlagDefinition(myFeatureFlag); -``` - -Then load it from the domain barrel so the top-level call runs at import time: - -```ts -// src//index.ts -import './flag'; -export * from './flag'; -``` - -`src/index.ts` already re-exports every domain barrel, so the contribution runs during bootstrap, before any scope is created — and therefore before any consumer resolves `IFlagService`. - -- `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`. -- `id` must not be `flag`. A duplicate `id` throws when `FlagRegistryService` drains the contributions. -- `FlagId` is `string`, not a literal union: with no central catalog there is nothing to derive it from, so `enabled()` has no compile-time typo-checking. Cover gated behavior with tests instead. -- `surface`: `core` | `tui` | `both` (documentation/grouping only; not used in resolution). - -## Consume a flag - -Inject `IFlagService` and gate on it. It is resolvable from any scope (App ancestor): - -```ts -constructor(@IFlagService private readonly flags: IFlagService) {} -// ... -if (!this.flags.enabled('my_feature')) return; -``` - -## Layering & scope - -- Domain `flag` imports only `config` downward. -- It cannot live in `_base`: registering/reading the config section requires importing `config`, and `_base` is pure infrastructure that must not know any business domain. -- Scope: `IFlagRegistry` and `IFlagService` are both `App`. Env + config are process-global inputs, so there is no per-session/agent state. Flag definitions are contributed at **import time** (top-level `registerFlagDefinition` calls), so they are queued before any scope is created and drained when `FlagRegistryService` is first instantiated — before `IFlagService` is first resolved. -- Tests build `FlagService` + `FlagRegistryService` directly with a real `ConfigRegistry`/`ConfigService` and an injected env map, then `register` the flags they exercise (`test/flag/flag.test.ts`). - -## References - -- `packages/agent-core-v2/src/flag/` — implementation (`IFlagRegistry` + `IFlagService`). -- `packages/agent-core-v2/src/agent/toolSelect/flag.ts` — example per-domain flag contribution. -- `packages/agent-core-v2/test/flag/flag.test.ts` — precedence + config subscription tests. -- `packages/agent-core/src/flags/` — v1 source this was ported from. -- `packages/agent-core-v2/GAP_ANALYSIS.md` §2.1 — gap closure note. -- Root `AGENTS.md` — experimental-feature gating rule. diff --git a/packages/agent-core-v2/docs/rw-model-design.md b/packages/agent-core-v2/docs/rw-model-design.md deleted file mode 100644 index bb6787ad7d3..00000000000 --- a/packages/agent-core-v2/docs/rw-model-design.md +++ /dev/null @@ -1,903 +0,0 @@ -# 统一读写模型设计(提案稿) - -> 目标:为 agent-core-v2 定义一套**唯一**的读写模型,统一 view、topic、写 operation、 -> 订阅方式,消解回环、定义方式不一致、事件可见性混乱等问题。本文基于对 -> agent-core-v2 / server-v2 / TUI(apps/kimi-code)三方现状的完整调研, -> 所有断言均有 file:line 证据。 -> -> 阅读顺序:§1 问题 → §2 概念模型(核心) → §3–§6 各原语规范 → §7 订阅协议 → -> §8 回环控制 → §9 迁移路径。附录 A 是"现有机制 → 新模型"的逐条映射。 -> -> **更新注**:本文档撰写时,`todo.set` / `turn.launch` / `context.splice` 仍是 -> agent-core-v2 的 wire record 类型。后续的重构(v1 vocabulary 对齐)已删除这三个 -> replay-only / pre-alignment 类型,统一改用 v1 的 `tools.update_store` -> (`key: 'todo'`)、`turn.prompt`、`context.append_message` 等。本文档中涉及这些 -> 类型的示例与映射,按上述替换理解。 - ---- - -## 0. 硬约束:持久层冻结,只统一接口 - -本设计**不改变任何落盘产物**: - -- `wire.jsonl` 的路径推导(`sha256(agentHomedir)[0:16]`、scope `'wire'`, - `wireRecordService.ts:66, 359-361`)不变; -- **每 agent 一个物理日志文件**的布局不变; -- `PersistedWireRecord` 的数据结构(record 类型字符串、字段、`metadata` - 信封、`time` 戳)不变,已存在的 18 个域的 record 形状逐字节兼容; -- `protocol_version` / 迁移链(1.0→1.5)机制不变,本设计**不引入新的 - 日志格式迁移**; -- fork 的实现(appendLogStore 层过滤复制 + 插入 `metadata`/`forked`)不变; -- server-v2 的 SessionEventJournal(第二本 journal)与 `{seq, epoch}` 线上 - 语义不变。 - -统一发生在**进程内 API 面**:写入口、读模型、订阅、相位、类型注册表。 -所有涉及存储布局的进一步收敛(session 单日志、seq 落盘、journal 合一) -移入附录 C 作为远期可选项,不在本期范围。 - ---- - -## 1. 现状与问题 - -### 1.1 现状一句话 - -核心已经是一个**半成品事件溯源系统**:每个 agent 一条 wire record 追加流 -(`wireRecordService.ts`),上面有统一门面 `IAgentRecordService` -(append / signal / define / defineView),但: - -- 声明式 view 只迁移了 2 个(contextMemory、contextSize),其余 ~12 个域仍是 - "append 记录 + 手写私有状态 + live/resume 两份 apply + 手动通知"; -- 同一事实最多有 **4 种表达**:wire record(`goal.update`)、AgentEvent signal - (`goal.updated`)、replay 记录(`goal_updated`)、getter snapshot(`getGoal()`); -- 事件机制 **6 种并存**:Emitter、OrderedHookSlot、ViewHandle.onChange、 - IEventService(无类型)、AsyncEventQueue、裸回调/Promise; -- 每个会话有 **两本追加日志、两套序号**:agent wire log(核心)+ - SessionEventJournal(server-v2 边缘,`sessionEventBroadcaster.ts:1-25`)。 - -### 1.2 问题清单(设计必须逐条回答) - -**写路径** -- W1 命令实现三风格并存:append+独立 apply(多数域)/ append 即 fold - (contextMemory)/ append 后复用 resume 函数(turn,`turnService.ts:57-83`)。 -- W2 `define` facet 合并语义注释与代码相反("first writer wins" vs 实际后者覆盖, - `recordService.ts:139-146`);dispose 只注销 resumer 不清 facets,与 `defineView` - 的完整清理不对称。 -- W3 Session 域借 main agent 的 wire 写(todo/cron),main 缺失时**静默丢写** - (`sessionTodoService.ts:99-100`),且要 `as never` 绕过类型。 -- W4 fork 直接在 appendLogStore 层改写 wire log,绕过全部写模型 - (`sessionLifecycleService.ts` 的 `fork` / `copyAgentWire`)。 -- W5 restore 期 append 在 wireRecord 层被静默吞掉(`wireRecordService.ts:81`), - 但 recordService 仍然 foldViews、仍然跑 facet——"进内存不进磁盘"完全隐式。 - -**读路径** -- R1 手写读模型 ~12 处(goal/usage/plan/swarm/permission*/turn/task/todo…), - live 与 resume 两份 apply 靠人肉保持一致。 -- R2 replay 读模型双通道:声明式 `toReplay` + 命令式 `push/patchLast/removeLastMessages`; - boundary 判定逻辑两处重复(`recordService.ts:55-64` vs `contextMemoryService.ts:137`)。 -- R3 `plan.status()` 读模型内嵌文件 IO;`sessionActivity.status()` 纯轮询无事件。 -- R4 `messageLegacy` 靠"replay 非空信 replay,否则信 view"的启发式选择读模型 - (`messageLegacyService.ts:100-116`)。 -- R5 `captureLiveRecords` 是无人使用的死开关;`IQueryStore` 有契约无实现。 - -**事件可见性** -- V1 `task.started/terminated` 在 WireRecordMap 和 AgentEvent 双注册,写路径 - append+signal 同名两连发(`taskService.ts:796-807`);`toLive` facet 全库仅 - permissionMode 一处使用。 -- V2 `agent.status.updated` 是"多域共写的散装快照事件":plan/swarm/usage/ - contextSize/profile 各自手动拼不同字段。 -- V3 resume 期 signal 靠 `emitLive` 隐式压制(skill/swarm)——"这个 signal 发不发 - 得出去"取决于调用时相位,调用点看不出来。 -- V4 `IEventService` payload 无类型、事件名裸字符串、同一事件两处发布者。 -- V5 `prompt.submitted` 曾长期只存在于协议而无人发,现已由 `AgentPromptService` - 在提交时发出(排队/运行以 `status` 区分,启动时再发 `prompt.started`); - `AsyncEmitter/handleVetos` 是死代码。 - -**回环与相位** -- L1 订阅者回写链真实存在且无统一约束:turn.onEnded→goal 续跑→再 launch turn; - loop.afterStep→steer flush→splice;onContextOverflow→compaction→splice→ - 可能再 overflow(靠显式计数器截断,`fullCompactionService.ts:100-105`)。 -- L2 `foldViews` 同步 fire change、无重入保护(`recordService.ts:282-295`): - onChange 处理器若 append 会无检测地重入。 -- L3 restore 正确性依赖三重隐式契约:DI 构造顺序 + hook 注册顺序 + - "resumer 先于 hooks";`doResume` 需手动预热 contextMemory - (现 `sessionLifecycleService.ts` 的 `doResume` / `materializeSession`)。 -- L4 相位规则(restoring / postRestoring / live)在 append/signal/push/hook - 四条通道上各不相同,没有一处集中定义。 - -**消费端(server-v2 / TUI)反推的需求** -- C1 server 需要:seq/epoch 水位、durable/volatile 二分、断线 backfill、 - snapshot-at-watermark(`snapshot.ts:1-14`)。这些今天全部在边缘重新发明 - (第二本 journal + InFlightTurnTracker 在边缘重建流式状态)。 -- C2 TUI 需要按实体订阅(transcript/toolCall/todo/运行状态/用量/模式/goal/ - 后台任务/子 agent/pending interactions),而不是自己从 44 种事件里 join; - TUI 适配层 ~4000 行,大量"补状态"hack(终态三方对账、入参反推 todo、 - 回放逆向工程、/tasks 轮询)。 -- C3 TUI 需要"历史回放 = 同一读模型冷启动 + seq 无缝接续";今天回放与实时是 - 两套独立代码,靠时间近似衔接,会丢窗口事件。 -- C4 写需要回声(renameSession 客户端自合成事件;v2 路由手发 - `event.session.created` 三遍,`sessions.ts:260,503,619`);乐观 UI 需要 - 确认/失败语义。 -- C5 protocol 已定义 durable seq + `VOLATILE_EVENT_TYPES` - (`protocol/src/events.ts:1475-1503`)但核心与 TUI 均未采用——分类应上移到定义处。 -- C6 **冷读必须先完整 resume**:v1 读消息历史触发整套 resume(snapshot p99 - 5s+ 的根因);v2 的 GET 会隐式创建 main agent(`tasks.ts:282`、`tools.ts:218`) - ——读有副作用,且"句柄不在就没有读模型"。 -- C7 session 聚合读模型缺失:`toWireSession` 一半字段是假值 - (status/usage/message_count,`sessions.ts:737-756`);session status 在 - v1 有三重独立计算。 -- C8 **wire 类型双份 + lossy 手写投影**:Goal/Usage/Task/PermissionRule 在 - core 与 protocol 逐字段重复;PermissionRule 无映射代码、wire 恒 `[]`; - question multi 答案被 `join(',')`;43 个 wire 事件中 8 个在 v2 无发射点。 -- C9 in-flight 流式状态在边缘折叠(InFlightTurnTracker),且显式丢弃 - subagent 事件(`inFlightTurnTracker.ts:15-17`)——"每 agent 一条流"与 - "每 session 一个 cursor"的张力未解决。 -- C10 pending approval/question 在 v1 是内存悬挂 Promise,掉电即失;v2 收进 - interaction 服务但仍非持久事实。 - ---- - -## 2. 概念模型 - -模型 = **5 个原语 + 1 个流结构 + 1 个相位机**。所有现有机制都映射进来 -(附录 A),不在这 5 类里的机制一律淘汰或降级为实现细节。 - -``` - ┌────────────────────────────────────────┐ - Command ──commit──▶ │ Stream(session 逻辑流,进程内 seq; │ - (决策,只在 live) │ 物理仍为 per-agent wire.jsonl,见 §0) │ - │ fact | signal 两类条目 │ - └──────┬─────────────────┬───────────────┘ - │ fold(同步) │ 统一订阅(边缘照旧 journal) - ▼ ▼ - View 图 订阅者(server/TUI) - (纯函数折叠) snapshot + since(seq) - │ - ▼ onChange(队列化派发) - Effect(live-only,只能发 Command) -``` - -### 2.1 五个原语 - -| 原语 | 一句话定义 | 回答的问题 | 对应成熟系统 | -|---|---|---|---| -| **Fact** | 已发生的、持久化的、可回放的事实 | "什么改变了状态" | ES 的 event、Kafka 的 record | -| **Command** | 验证 + 决策,产出 0..n 个 Fact;自身无状态、不回放 | "谁决定改变" | CQRS 的 command、Redux 的 action creator | -| **View** | Fact 流上的纯函数折叠,唯一的状态载体 | "状态是什么" | Redux reducer+selector、Kafka Streams 的 KTable | -| **Signal** | 类型化、注册制的易失事件,永不持久化、不参与折叠 | "过程进行到哪了" | CDP 的 streaming event、protocol 的 volatile | -| **Effect** | 订阅 Fact/View 变化、只能通过 Command 回写的策略 | "事实引发什么后续" | ES 的 process manager / saga | -| **Hook**(保留,不变) | 写操作内的有序参与/否决 | "谁能拦截这次操作" | koa middleware、VS Code participant | - -判词(替代 service-design.md §4 的扩展): - -> - "这件事**已经发生**且 resume 后必须还在" → **Fact**(commit)。 -> - "我要**决定**是否让它发生、怎么发生" → **Command**(service 方法)。 -> - "我要知道**现在的状态**" → **View**(get/onChange),绝不再手写私有字段。 -> - "这只是**进行中的进度**,断线丢了也无所谓" → **Signal**。 -> - "事实发生后**系统要接着做**某事" → **Effect**(live-only)。 -> - "这次操作执行**过程中**我要参与/否决" → **Hook**(不变)。 - -### 2.2 流结构(Stream / Topic)——逻辑流,物理布局不变(§0) - -- **逻辑上每个 Session 一条流,按 `agentId` 分区**;**物理上仍是每 agent 一个 - wire.jsonl**,session 流是各 agent 日志的进程内缝合视图。写 API 按分区路由到 - 对应 agent 的物理日志,读/订阅方只面对逻辑流。 -- **session 级事实(`todo.set`、`cron.*`)物理上继续落 main agent 的 - wire.jsonl**(数据兼容,record 形状不变),但接口上收进 - `sessionStream.commit(fact)`:类型安全(消灭 `as never`)、main 不存在时 - **抛错或显式排队**而不是静默丢写(W3 的接口层解法;物理归位是附录 C 远期项)。 -- **seq 是进程内的逻辑序号**:session 流上单调递增,**不落盘**(数据结构冻结)。 - 它用于 view 版本号、写回声、进程内订阅游标;跨重启的持久游标仍由 server 的 - SessionEventJournal 承担(现状不变)。核心保证:转发给边缘的事件顺序 = - 逻辑 seq 顺序,因此边缘 journal 的 seq 与核心逻辑 seq 单调一致。 -- fork 保持现实现(复制 main 的 wire log);接口上表达为 - `stream.forkInto(target)`,实现仍走 appendLogStore(W4 的接口层收口: - 唯一入口,不再散落在 sessionLifecycle 里手写)。 -- App scope 一条逻辑流(config/model catalog/session 生命周期),取代 - `IEventService`(V4)——App 流本就无持久化,纯接口替换。 -- **Topic = 流上的类型化过滤视角**,不是独立机制。订阅方用 - `subscribe({types?, agentId?, sinceSeq})` 表达,服务端不为每个 topic 建通道。 - -### 2.3 相位机(唯一的一处定义) - -``` -replaying ──(日志折叠完)──▶ ready ──(首个 live commit)──▶ live -``` - -| 相位 | commit(fact) | View fold | View onChange | Signal | Effect | -|---|---|---|---|---|---| -| replaying | **抛错**(编程错误) | ✅(静默) | ❌ | **抛错** | ❌ 不运行 | -| ready→live | ✅ | ✅ | ✅(队列化) | ✅ | ✅ | - -对比现状:restore 期 append 被静默吞(W5)、signal 被隐式压制(V3)、四条通道 -各有各的相位规则(L4)。新模型里**相位规则只在 commit/emit/fold/effect 四个入口 -各写一次**,且违规是响声(throw)不是静默。 - -> 今天"resume 里合法地想写"的场景(goal 的 fork reminder 每次 restore 重新 -> 生成)改由 **AgentReminder** Runtime 的 `register` provider 或 ready 相位的 -> 一次性 Effect 承担——派生内容本来就不该伪装成回放副作用。 -> `postRestoring` 窗口取消:task 磁盘对账、cron 启动等归入 ready 时刻的 -> 一次性 Effect。 - ---- - -## 3. 类型系统:单一注册表 + 定义处声明可见性 - -### 3.1 一个注册表,两类条目 - -保留 declaration-merging 开放注册表模式(与 ErrorCodes/FlagRegistry/config -sections 一致),但把 `WireRecordMap`(18 个增补点)、`AgentEvent`(protocol 44 -种)、`AgentReplayRecordPayload`(7 种)三套宇宙合并为一个 `EventMap`,每个条目 -在**定义处**声明它是 fact 还是 signal: - -```ts -// 域内声明(declaration merging,与今天相同的写法) -declare module '#/stream' { - interface EventMap { - 'todo.set': Fact<{ todos: readonly TodoItem[] }, { scope: 'session' }>; - 'goal.update': Fact; - 'assistant.delta': Signal<{ turnId: number; text: string }>; - 'tool.progress': Signal; - } -} -``` - -- **可见性是类型属性,不是调用点决策**(解决 V1/V3):`commit()` 只接受 Fact - 条目,`emit()` 只接受 Signal 条目,用错了编译不过。`task.started` 双注册、 - append+signal 两连发的写法从类型上消失。 -- **数据兼容**(§0):Fact 条目的类型字符串与 payload 形状 = 现有 - `WireRecordMap` 条目,逐字节不变;Signal 条目 = 现有 volatile `AgentEvent`。 - 合并只发生在类型注册表层面,不产生新的落盘/线上形状。 -- protocol 的 `VOLATILE_EVENT_TYPES` 从这个注册表**生成**(signal 即 volatile), - 分类只此一处(C5)。 -- `blobs`(大内容 offload)仍是 Fact 定义的属性,随条目声明。 -- **线上协议(AgentEvent)本期不变**:Fact → AgentEvent 的投影保留,但从 - "散落在各域的 toLive facet / 手动 signal"收敛为 Fact 定义处的唯一 - `live(payload): AgentEvent | undefined` 声明。`agent.status.updated` 这类 - 多域共写事件(V2)由各相关 view 的 onChange 统一驱动一个投影器发出, - 不再各域手拼。wire 类型单源化(C8,protocol schema 从 EventMap/view 类型 - 生成)是方向性目标,放在附录 C 远期项,本期只做"投影函数与类型同处声明、 - 禁止路由层手写投影"。 - -> 兼容注:v1 协议消费者(messageLegacy/sessionLegacy)保留为边缘的翻译层, -> 从新 Envelope 流翻译到旧 shape,不再反向影响核心模型。 - -### 3.2 与 contract 生成的关系 - -`gen-contract-types.mjs` 剥实现、留接口的方向不变:`EventMap`、View 输出类型、 -Command 接口就是 contract 面;`defineFact/defineView/defineEffect` 的注册调用 -发生在实现类构造器中,会被剥除。若共享折叠代码给客户端(§7.3),view 的纯函数 -部分单独放 `viewDefs/`(无 DI 依赖),可被 contract 打包。 - ---- - -## 4. 写路径规范 - -### 4.1 Command:决策与状态分离 - -```ts -// 唯一合法形态(W1 三风格 → 一风格) -setTodos(todos: TodoItem[]): void { - // 1. 验证/决策(可读 view、可跑 hook、可有副作用补偿逻辑) - const next = normalize(todos); - // 2. 产出事实(0..n 个) - this.stream.commit({ type: 'todo.set', todos: next }); - // 3. 没有第 3 步:不改私有字段、不手动 fire —— 状态由 view 折叠,通知由 view 发 -} -``` - -规则: -- **Command 不持有可折叠状态**。所有"resume 后必须还在"的状态在 view 里。 - service 私有字段只允许装真正的运行时资源(进程句柄、定时器、连接)。 -- **Command 不在 replay 中运行**(相位机保证)。resume 复用 live 命令的 hack - 消失:replay 只折叠 fact。 -- 需要"先答应再补偿"的命令(plan.enter 失败后 cancel)就是两次 commit—— - 补偿也是事实,天然可回放。 -- `define()` 的 facet 机制退役:`resume` → view fold;`toLive` → 定义处 - redact;`toReplay` → transcript view(§5.3);`blobs` → Fact 定义属性。 - W2 的合并/dispose 语义问题随 API 一起消失。 - -### 4.2 写回声与因果(C4) - -`commit()` 返回 `{ seq }`。RPC 写接口把它透传给客户端,乐观 UI 用 -"本地暂挂 → 收到 ≤seq 的确认即落定"的标准 rebase 模式(Replicache 的 -mutation-id 思路的最简版)。`renameSession` 这类"写无回声"从此不可能—— -写就是 commit,commit 必然出现在流里。 - ---- - -## 5. 读路径规范:View 三层 - -### 5.1 状态 View(迁移 R1 的 ~12 个域) - -现有 `View`(`record.ts:57-68`)已经是正确形态, -推广为唯一状态载体,并补三件事: - -1. **版本号**:`ViewHandle.get()` 返回 `{ value, seq }`——值与水位一致, - snapshot 路由不再需要"drain queue 再读"的舞蹈(`snapshot.ts:10-14`)。 -2. **派生组合**:`derive(view A, view B, f)` 只读组合器(同步、纯函数), - 替代 `sessionActivity.status()` 式的跨服务现拼轮询(R3)、 - `permissionGate.data()` 式的手工拼装。组合器不新建折叠状态,只做缓存+ - 变更传播(等价 Redux reselect / VS Code derived observable)。 -3. **禁止 IO**:view 输出必须纯内存。`plan.status()` 读文件 → 拆成 - "planFilePath 状态 view" + 调用方自己读文件(或 Effect 缓存文件内容为 view)。 - -`agent.status.updated`(V2)退役:它的每个字段来自某个 view,订阅方直接订 -对应 view / 对应 topic,不再有"多域共写的散装快照事件"。 - -### 5.2 跨 scope View - -Session 级 view(todo、后台任务表、pending interactions、sessionActivity) -折叠 session 分区 + 需要的 agent 分区。TUI 要的"后台任务表带终态"(C2)在这里 -成为一等 view:折叠 `task.started/terminated` + `subagent.*` fact,终态对账 -逻辑从 TUI 的 50 行注释搬进一个纯函数。 - -### 5.3 Transcript View(替代 replay builder,解决 R2/R4/C3) - -UI 历史(今天的 `AgentReplayRecord[]`)就是一个折叠: -`transcript = fold(facts)`,输出结构化的 -`Turn[] → Step[] → (Message | ToolCall{call,result,progress?})`。 - -- 双通道(toReplay + push/patchLast)消失;fullCompaction 的 patchLast 补写 - 变成 fold 里对 `full_compaction.complete` 的常规 case。 -- boundary/裁剪逻辑(partial resume 的 range/segment/frozen)成为 fold 的 - 参数化初始条件,只写一处。 -- messageLegacy 的"replay 或 view"启发式消失:冷启动与热读取是同一个 view。 -- TUI 的 resume:`GET snapshot` 拿 `{ transcript.get(), seq }` → - `subscribe(sinceSeq)` 接续。回放与实时一套代码(C3)。 - -### 5.4 流式增量的归宿(TUI 需求 §4) - -Signal 不折叠进持久 view,但**规范其形态**:流式文本 signal 携带 -`{ turnId, stepId, cumulative: string }`(累计文本)或定期 checkpoint, -配合 fact 上的 finalize 边界(`turn.step.completed` 等已是 fact)。 -TUI 的 50ms 节流、相位切换 finalize 由"cumulative + 边界 fact"天然支持, -乱序/丢失的容忍度大幅提高(丢 signal 只丢中间帧,边界由 fact 保证)。 - -### 5.5 Ephemeral View(收编 InFlightTurnTracker,解决 C9) - -第四类 view:**折叠 fact + signal、只活在 live 相位**的视图(重启/resume 后 -从空态重建,不参与回放)。声明方式与状态 view 相同,多一个 -`ephemeral: true` 标记。用途: - -- `inFlightTurn`:今天 server 边缘的 `InFlightTurnTracker`(只跟 main、 - 丢弃 subagent)成为核心标准 ephemeral view,按 agentId 分区折叠—— - subagent 的张力消失,因为 session 只有一个 seq(§2.2); -- TUI 的 `streamingPhase`:从"客户端猜测的派生状态"变成核心 ephemeral view - 的字段。 - -snapshot 包含 ephemeral view 的当前值(与 seq 一致),所以断线重建不丢 -进行中状态;但它们不写日志、不回放——这就是"volatile 流可折叠"的规范答案。 - -### 5.6 冷读与物化(解决 C6/C7) - -view 是纯 fold,因此**天然支持冷读**:不实例化 agent/session scope,直接 -`foldOffline(log, viewDef)` 即可得到任意 view 的值。规范两个消费面: - -- **冷读 API**:`readView(sessionId, name)`——句柄在(热)读内存,句柄不在 - (冷)从日志折叠,读语义一致;**读永不触发 resume、永不创建 agent** - (消灭 GET 建 main agent、读消息触发整套 resume)。 -- **session 聚合视图**:`sessionSummary`(status/usage/messageCount/lastSeq/ - title)定义为跨分区 fold——正是 `toWireSession` 今天造假的字段。 - `ISessionIndex` 的列表条目从"目录树即索引"升级为该 view 的磁盘物化 - (`IQueryStore` 契约在此落地:projector = view fold,checkpoint = seq), - 列表页不再打开每个 session 的日志。 - ---- - -## 6. 事件机制收敛 - -| 现机制 | 去向 | -|---|---| -| `Emitter`(28 处) | View.onChange 覆盖状态类;仅保留给真正的运行时资源事件(进程输出、fs watch) | -| `OrderedHookSlot`(24 slot) | **保留原样**——它服务写路径的参与/否决(tool 执行、prompt 构建、loop 步进),与读模型正交 | -| `ViewHandle.onChange` | 保留,通知派发队列化(§8) | -| `IEventService` | 并入 App 流(类型化 fact/signal) | -| `AsyncEventQueue` | 保留为 LLM 流适配的内部实现细节;删兼容 re-export | -| `AsyncEmitter`/`handleVetos` | 删(死代码,能力已由 HookSlot 承担) | -| 裸回调(onUpdate 等) | 工具执行进度改发 Signal;RPC 反向调用(审批/提问)保留 | - -`wireRecord.hooks.onRestoredRecord / onResumeEnded` 退役:restore 编排收进 -相位机(fold 全部 → ready 一次性 Effect),L3 的三重隐式顺序契约消失。 - ---- - -## 7. 订阅协议(server 与 TUI 的统一消费面) - -### 7.1 进程内订阅面(线协议本期不变) - -``` -核心暴露(进程内): - sessionStream.subscribe({ sinceSeq?, types?, agentId? }) - → AsyncIterable<{ seq, time, agentId, kind: 'fact'|'signal', type, payload }> - readView(sessionId, name) → { value, seq } // 冷热一致,见 §5.6 -``` - -- **seq 是核心的进程内逻辑序号**(§2.2):commit/emit 时分配、单调、不落盘。 - view 版本号、写回声、Effect 因果标记都引用它。 -- **server-v2 广播器保留现职**(journal、持久 `{seq, epoch}`、backfill、 - resync,线上协议零改动),但消费源从"逐 agent 订阅 `record.on` + 生命周期 - 追补"(`sessionEventBroadcaster.ts:256-275`)换成**一次订阅 session 逻辑流**: - agent 增删、agentId/sessionId 附加、durable/volatile 分类(来自注册表) - 都由核心做完。边缘的 seq 与核心逻辑 seq 单调一致,snapshot 的 - "drain queue 后原子读"简化为"读 view 的 `{value, seq}`"。 -- 断线重连/epoch/resync 语义完全沿用现协议(`ResyncReason` 不变)。 -- journal 合一(删除边缘第二本账,C1 的彻底解)依赖 seq 落盘,属于附录 C - 远期项;本期 C1 的接口层收益是:边缘不再自己发明分类、缝合与一致性舞蹈。 - -### 7.2 server-v2 变薄 - -边缘保留 journal/seq/epoch/backfill(§0、§7.1),其余变薄:鉴权、连接管理、 -统一流直通(durable/volatile 分类、agent 缝合、投影都由核心做完)、 -REST 读路由 = `readView()` 的透传(热/冷一致,§5.6)。snapshot 路由从 -"跨 6 个服务现拼 + drain queue 保一致"(`sessionLegacyService.ts:278-300`、 -`snapshot.ts:10-14`)变成"读若干 view 的 `{value, seq}`"。写路由 = Command -的透传(actionMap 的 `resource:action` allowlist 模式保留,它已经证明 -"命令 = Service 方法"可行);路由层手发事件(C4)被"写即 commit、commit -必在流里"取代。pending approval/question 升格为持久 fact + -`pendingInteractions` view(C10):审批请求/决议都是事实,掉电不失, -且 wire 投影不再靠 `as ApprovalRequest` 断言。 - -### 7.3 客户端读模型(可选进阶) - -view 定义是无依赖纯函数(§3.2),可经 contract 包共享给 node-sdk/TUI: -客户端 `fold(snapshot, envelopes)` 增量维护同一批 view。TUI 的 4000 行适配层 -中"join 事件重建状态"的部分(终态对账、todo 反推、streamingPhase 猜测)由 -共享 fold 取代。这一步不阻塞核心重构,可后置。 - ---- - -## 8. 回环控制 - -三条机制,全部集中在 stream 实现里: - -1. **提交队列**:`commit()` 同步折叠所有 view,但 **onChange 通知入队**, - 当前 commit 栈退出后按序派发(等价 VS Code observable 的事务、Redux 的 - dispatch-in-reducer 禁令)。onChange 处理器里再 commit → 入队排后, - 不重入折叠(解决 L2)。同一 microtask 内多次变更可合并(views 天然支持 - equals 去重)。 -2. **Effect 注册制**:订阅者回写(L1 的 goal 续跑、swarm 自动退出、steer - flush、overflow→compaction)显式注册为 - `defineEffect(name, { on: [...types] | view, run(ctx) })`: - - 只在 live 相位运行(替代 4 处手写 restoring guard); - - 只能调 Command(不能直接 commit 裸 fact,保证决策逻辑不被绕过); - - Effect 产生的 fact 带 `cause: { effect, seq }` 因果标记,日志里 - 回环可审计;同一 Effect 对同一 cause 链的触发深度设上限(默认 1), - overflow→compaction→overflow 这类循环从"每处手写计数器"变成声明 - `maxCauseDepth`。 -3. **相位机**(§2.3):replay 期 commit/emit 抛错,Effect 不运行——回环 - 在回放路径上物理不存在。 - ---- - -## 9. 迁移路径(每步独立可交付,不破坏现有消费者) - -1. **P0 止血**(不改架构):修 `define` 合并/dispose 语义(W2);restore 期 - append 从静默吞改为 assert/log(W5 显形);删死代码(AsyncEmitter、 - 兼容 re-export、captureLiveRecords)。 -2. **P1 注册表合一**:EventMap + Fact/Signal 二分 + `commit/emit` 新 API - (旧 append/signal 作为别名过渡);`VOLATILE_EVENT_TYPES` 改为生成。 -3. **P2 view 化推平**:按依赖序迁移 12 个手写域到 view(goal 最复杂放最后); - 引入 `derive` 组合器,改造 sessionActivity/permissionGate。 -4. **P3 transcript view**:以 fold 重写 replay builder,双通道退役; - messageLegacy 改读 transcript view。 -5. **P4 相位机 + Effect**:收编 onRestoredRecord/onResumeEnded/postRestoring; - 四处 restoring guard、goal silent 抑制改 Effect/队列;pending interaction - 持久 fact 化 + ephemeral `inFlightTurn` view(server tracker 退役的前置)。 -6. **P5 逻辑流与订阅面**:session 逻辑流(缝合现有 per-agent wire.jsonl, - 物理布局不变);进程内逻辑 seq;`sessionStream.commit` 收编 todo/cron 借道 - 写;`forkInto` 收口 fork;server-v2 broadcaster 改为消费统一流(线上协议 - 不变);`readView` 冷读 + `sessionSummary` 物化(新增索引文件,不触碰 - wire.jsonl)。 -7. **P6(可选)**:共享 view 折叠到客户端;TUI 适配层瘦身;wire 类型单源化 - 收尾(protocol schema 从 EventMap/view 类型生成)。 - -存储层的进一步收敛(附录 C)全部不在本期:P1–P5 均不产生新的日志格式或 -迁移器。 - -P1–P4 在核心内部完成,对 server/TUI 完全透明;P5 需要 server-v2 配合一次 -协议升级(Envelope 字段不变,seq 语义从边缘改核心)。 - ---- - -## 10. 与成熟系统的对照(控制复杂度的锚点) - -| 借鉴 | 采纳的原语 | 明确不采纳的 | -|---|---|---| -| Event Sourcing / CQRS | fact 即真相、command/query 分离、projection、process manager | 聚合根/仓储层——scope 容器已承担边界 | -| Redux / Elm | 纯 fold、selector 组合、dispatch 队列 | 全局单 store——按 scope 分流 | -| Kafka | 分区日志、offset 即 seq、consumer 自带游标 | broker/consumer group——单机进程内不需要 | -| Replicache / LiveStore | 客户端共享 fold、mutation 回声 rebase | CRDT 合并——单写者(核心)无并发写 | -| VS Code | Emitter 风格 API、observable 事务式派发、contract/impl 分离 | — | -| CDP / LSP | domain 事件 + snapshot-then-stream、volatile 分类 | — | -| XState | 显式相位机 | 层级状态机——只有 3 个相位,不值得 | - -复杂度预算:新模型的**机制数从 6+4(事件×相位)降到 5+1+3** -(原语×流×相位),且每个问题(W/R/V/L/C 共 21 条)都能指出由哪个机制消解 -(附录 A)。 - ---- - -## 附录 A:问题 → 机制映射 - -| 问题 | 消解机制 | -|---|---| -| W1 三风格命令 | §4.1 唯一 Command 形态 | -| W2 define 语义 | §4.1 facet 退役(P0 先修复) | -| W3 借 main wire | §2.2 sessionStream 类型化接口(物理仍落 main wire,缺 main 时响声) | -| W4 fork 绕写模型 | §2.2 forkInto 唯一入口(实现不变) | -| W5 静默吞 append | §2.3 replay 期 commit 抛错 | -| R1 手写读模型 | §5.1 状态 view 推平 | -| R2 replay 双通道 | §5.3 transcript view | -| R3 读模型带 IO/轮询 | §5.1 禁 IO + derive 组合器 | -| R4 replay-or-view 启发式 | §5.3 冷热同源 | -| R5 死开关/空契约 | P0 删除;IQueryStore 待 P5 后按需实现为磁盘物化 view | -| V1 双注册两连发 | §3.1 Fact/Signal 二分,类型强制 | -| V2 散装快照事件 | §5.1 按 view 订阅 | -| V3 隐式压制 | §2.3 相位规则响声化 | -| V4 无类型总线 | §2.2 App 流 + EventMap | -| V5 死代码 | P0 删除 | -| L1 订阅者回写 | §8.2 Effect 注册制 + 因果深度 | -| L2 同步 fire 重入 | §8.1 提交队列 | -| L3 restore 顺序契约 | §2.3 相位机收编 | -| L4 相位规则分散 | §2.3 唯一定义处 | -| C1 两本 journal | §7.1 边缘改消费统一流(journal 合一 → 附录 C) | -| C2 按实体订阅 | §5 view 体系 + §7.1 types 过滤 | -| C3 回放=冷启动 | §5.3 + §7.1 snapshot/sinceSeq | -| C4 写回声/路由手发事件 | §4.2 commit 返回 seq + §7.2 | -| C5 volatile 分类分散 | §3.1 注册表生成 | -| C6 冷读需 resume/读有副作用 | §5.6 readView 冷热一致 | -| C7 session 聚合假值 | §5.6 sessionSummary 物化 view | -| C8 wire 类型双份 | §3.1 单源化 | -| C9 in-flight 边缘折叠/subagent 丢弃 | §5.5 ephemeral view + §2.2 单 seq | -| C10 pending interaction 掉电即失 | §7.2 持久 fact 化 | - -## 附录 B:开放问题 - -1. session 逻辑流的缝合序:多 agent 并发 commit 时逻辑 seq 的分配点 - (建议:session 级单调计数器,commit 队列内分配,天然全序); - sub-agent 高频写是否需要独立背压。 -2. Signal 是否需要背压/合帧策略下沉到核心(今天 TUI 自己 50ms 节流)—— - 建议核心提供 per-type 合帧提示(`coalesce: 'replace' | 'append'`), - 边缘执行。 -3. goal 域状态大(预算/心跳/续跑),view 化后 fold 性能与 fact 粒度需要 - 专门设计(可能拆多个子 view)。 -4. `sessionSummary` 物化索引的存储位置与失效策略(新文件,不碰 wire.jsonl; - 建议 seq checkpoint + 日志 mtime 双校验)。 - -## 附录 C:远期存储层收敛(本期明确不做) - -以下项都依赖打破 §0 的冻结约束,留待接口统一稳定后单独立项: - -1. **session 单日志分区**(物理合并 per-agent wire.jsonl,todo/cron 归位 - session 分区),需要 v1.6 迁移器;收益:fork 语义更准、缝合层消失。 -2. **seq 落盘**(日志偏移即持久水位),之后才能删除 server 的 - SessionEventJournal(C1 的彻底解)与边缘 tail。 -3. **wire 类型单源化收尾**:protocol zod schema 从 EventMap/view 输出类型 - 生成,消灭 Goal/Usage/Task/PermissionRule 双份定义。 -4. v1.5 迁移器已内置 mini 回放机;若未来做 1/2 项,迁移应一次性偿还, - 避免继续在迁移器里堆语义。 - -## 附录 D:接口与场景代码示例 - -> 示例遵循仓库现有习惯:contract 文件放接口 + `createDecorator`,实现类构造器 -> 里做运行时注册(可被 `gen-contract-types` 剥离),类型注册表用 declaration -> merging。所有示例均满足 §0 冻结约束:不新增落盘格式。 - -### D.0 核心接口(`#/stream` contract) - -```ts -// ---- 类型注册表:两类条目,可见性即类型属性(§3.1) ---- -export interface FactMap {} // 各域增补:'todo.set' → payload 形状(= 现 WireRecordMap,逐字节兼容) -export interface SignalMap {} // 各域增补:'assistant.delta' → payload 形状(= 现 volatile AgentEvent) -export interface ViewMap {} // 各域增补:view 名 → 输出类型(沿用现 record.ts:47) - -export type Fact = - { [T in K]: { readonly type: T; readonly time?: number } & Readonly }[K]; -export type Signal = - { [T in K]: { readonly type: T } & Readonly }[K]; - -/** 提交回执:进程内逻辑 seq(不落盘,§2.2),写回声 / 乐观 UI 用(§4.2)。 */ -export interface CommitReceipt { readonly seq: number } - -/** Fact 的定义处声明(取代 define() 的 facets,§4.1)。 */ -export interface FactOptions { - /** 唯一的 live 投影(取代散落的 toLive/手动 signal,V1/V2)。undefined = 不广播。 */ - readonly live?: (fact: Fact) => AgentEvent | undefined; - /** 大内容 offload 选择器(沿用现 blobs 语义)。 */ - readonly blobs?: WireRecordBlobSelector>; -} - -export interface View { - readonly init: TState; - select(fact: Fact): TPayload | undefined; // 过滤 + 提取 - reduce(state: TState, payload: TPayload, fact: Fact): TState; // 纯函数 - derive?(state: TState): TOutput; - equals?(a: TOutput, b: TOutput): boolean; - /** true = 折叠 Signal、只活在 live 相位、进 snapshot 不回放(§5.5)。 */ - readonly ephemeral?: boolean; - selectSignal?(signal: Signal): TPayload | undefined; // 仅 ephemeral view 可声明 -} - -export interface ViewHandle { - /** 值与水位一致读(§5.1),snapshot 不再需要 drain-queue 舞蹈。 */ - get(): { readonly value: T; readonly seq: number }; - onChange(h: (c: { old: T; new: T; seq: number }) => void): IDisposable; // 队列化派发(§8.1) -} - -export interface EffectContext { - readonly cause: { readonly type: string; readonly seq: number; readonly depth: number }; -} -export interface EffectSpec { - readonly on: readonly (keyof FactMap)[]; // 或 { view: keyof ViewMap } - /** 因果深度上限:Effect 引发的 fact 再触发本 Effect 的最大链深(§8.2),默认 1。 */ - readonly maxCauseDepth?: number; - run(fact: Fact, ctx: EffectContext): void | Promise; // 只能调 Command,不能裸 commit -} - -export type StreamPhase = 'replaying' | 'ready' | 'live'; - -/** Agent 分区(物理 = 该 agent 的 wire.jsonl,不变)。 */ -export interface IAgentStream { - readonly _serviceBrand: undefined; - readonly phase: StreamPhase; - - commit(fact: Fact): CommitReceipt; // replaying 期抛错(§2.3) - emit(signal: Signal): void; // replaying 期抛错 - - defineFact(type: K, opts?: FactOptions): IDisposable; - defineView(name: K, view: View): IDisposable; - view(name: K): ViewHandle; - defineEffect(name: string, spec: EffectSpec): IDisposable; - /** ready 时刻一次性回调(取代 onResumeEnded/postRestoring,L3/L4)。 */ - onReady(fn: () => void | Promise): IDisposable; -} -export const IAgentStream = createDecorator('agentStream'); - -/** Session 逻辑流:各 agent 分区的缝合视图 + session 级事实(§2.2)。 */ -export interface ISessionStream { - readonly _serviceBrand: undefined; - /** session 级 fact:物理落 main agent wire(数据兼容);main 缺失时抛错,不再静默丢(W3)。 */ - commit(fact: Fact): CommitReceipt; - defineView(name: K, view: View): IDisposable; - view(name: K): ViewHandle; - /** 统一订阅面(§7.1):server 广播器唯一消费入口,agent 缝合/分类由核心做完。 */ - subscribe(opts: { - sinceSeq?: number; - types?: readonly string[]; - agentId?: string; - }, handler: (e: { - seq: number; time: number; agentId: string; - kind: 'fact' | 'signal'; event: AgentEvent; // 线上形状不变(§0) - }) => void): IDisposable; - /** fork 唯一入口(W4);实现仍是 appendLogStore 层复制,不变。 */ - forkInto(targetSessionId: string): Promise; -} -``` - -### D.1 场景:todo 域重写(三重记账 → Command + View) - -今天:`setTodos` 改私有字段 + `append`(`as never`)+ 手动 fire;resume 另有一份 -只改字段不通知的 resumer(`sessionTodoService.ts:84-113`)。重写后: - -```ts -// ---- 类型声明(payload 与现 wire.jsonl 中的 todo.set 逐字节相同) ---- -declare module '#/stream' { - interface FactMap { 'todo.set': { todos: readonly TodoItem[] } } - interface ViewMap { todo: readonly TodoItem[] } -} - -// ---- view:live 与 resume 唯一的一份状态逻辑 ---- -const todoView: View = { - init: [], - select: (f) => (f.type === 'todo.set' ? f.todos : undefined), - reduce: (_state, todos) => todos, -}; - -export class SessionTodoService extends Disposable implements ISessionTodoService { - constructor(@ISessionStream private readonly stream: ISessionStream) { - super(); - this._register(stream.defineView('todo', todoView)); - } - - /** Command:验证 + commit,没有第三步(§4.1)。 */ - setTodos(todos: readonly TodoItem[]): CommitReceipt { - const next = todos.map(({ title, status }) => ({ title, status })); - return this.stream.commit({ type: 'todo.set', todos: next }); - // 不改私有字段(状态在 view);不 fire(通知由 view.onChange); - // main agent 缺失 → commit 抛错(今天是静默丢写); - // resume 后 todo 自动就位(view 回放折叠),不需要 resumer。 - } - - getTodos(): readonly TodoItem[] { - return this.stream.view('todo').get().value; - } -} -``` - -### D.2 场景:goal 状态与 live 投影(四种表达 → 一种) - -今天 goal 有四套词汇:`goal.update` record、`goal.updated` signal、 -`goal_updated` replay 记录、`getGoal()` getter。重写后只剩 fact + view: - -```ts -declare module '#/stream' { - interface FactMap { - 'goal.create': { goal: GoalInit } - 'goal.update': { patch: GoalPatch } // 增量事实,形状不变 - 'goal.clear': {} - } - interface ViewMap { goal: GoalSnapshot | null } -} - -export class AgentGoalService extends Disposable implements IAgentGoalService { - constructor(@IAgentStream private readonly stream: IAgentStream) { - super(); - // live 投影在定义处声明一次:取代手动 signal('goal.updated')(V3 的响声化也在此: - // replay 期根本不会走到投影,无需隐式压制) - this._register(stream.defineFact('goal.update', { - live: (f) => ({ type: 'goal.updated', patch: f.patch }), - })); - this._register(stream.defineView('goal', goalView)); // fold 见下 - } - - /** 高频预算更新:silent 抑制不再需要——view.equals 去重 + 通知队列合帧(§8.1)。 */ - recordTokenUsage(usage: TokenUsage): void { - this.stream.commit({ type: 'goal.update', patch: { usage } }); - } - - getGoal(): GoalSnapshot | null { - return this.stream.view('goal').get().value; - } -} - -const goalView: View = { - init: EMPTY_GOAL_STATE, - select: (f) => - f.type === 'goal.create' ? { kind: 'create', goal: f.goal } - : f.type === 'goal.update' ? { kind: 'patch', patch: f.patch } - : f.type === 'goal.clear' ? { kind: 'clear' } - : undefined, - reduce: applyGoalFold, // 原 restoreUpdate/appendStatusUpdate 两份平行逻辑合一(R1) - derive: toSnapshot, - equals: goalSnapshotEquals, // 预算微变不触发通知(取代 silent 标志) -}; -``` - -### D.3 场景:派生组合 view(替代轮询式 sessionActivity) - -```ts -declare module '#/stream' { - interface ViewMap { - pendingInteractions: readonly PendingInteraction[] - activeTurns: ReadonlyMap - sessionActivity: SessionStatus // 派生,无自有折叠状态 - } -} - -// derive:只读组合器(§5.1),同步纯函数 + 变更传播;无轮询、无跨服务现拼 -sessionStream.defineView('sessionActivity', deriveViews( - ['pendingInteractions', 'activeTurns'], - (pending, turns): SessionStatus => { - if (pending.some((p) => p.kind === 'approval')) return 'awaiting_approval'; - if (pending.some((p) => p.kind === 'question')) return 'awaiting_question'; - if (turns.size > 0) return 'running'; - return 'idle'; - }, -)); -``` - -### D.4 场景:ephemeral view `inFlightTurn`(收编边缘 InFlightTurnTracker) - -```ts -declare module '#/stream' { - interface SignalMap { - 'assistant.delta': { turnId: number; stepId: number; cumulative: string } // 累计文本(§5.4) - 'tool.progress': { toolCallId: string; channel: 'stdout' | 'stderr'; chunk: string } - } - interface ViewMap { inFlightTurn: InFlightTurn | null } -} - -const inFlightTurnView: View = { - ephemeral: true, // 折叠 signal、live-only、进 snapshot 不回放(§5.5) - init: NO_TURN, - select: (f) => // fact 提供边界 - f.type === 'turn.launch' ? { kind: 'start', turnId: f.turnId } - : undefined, - selectSignal: (s) => // signal 提供进行中内容 - s.type === 'assistant.delta' ? { kind: 'text', ...s } - : s.type === 'tool.progress' ? { kind: 'tool', ...s } - : undefined, - reduce: foldInFlight, // 原边缘 tracker 逻辑搬进核心,subagent 不再被丢弃(C9) - derive: (st) => st.turn, -}; -``` - -### D.5 场景:Effect(订阅者回写的唯一合法形态) - -```ts -// swarm 自动退出:今天挂在 turn.hooks.onEnded 里直接写(L1) -export class AgentSwarmService extends Disposable { - constructor(@IAgentStream private readonly stream: IAgentStream) { - super(); - this._register(stream.defineEffect('swarm-auto-exit', { - on: ['turn.ended'], // 只在 live 相位运行;replay 期物理不存在(§8.3) - run: () => { - if (this.isActive()) this.exit(); // 只能调 Command——exit() 内部 commit - }, - })); - } -} - -// overflow → compaction:手写 consecutiveOverflowCompactions 计数器 → 声明式深度上限 -stream.defineEffect('overflow-compaction', { - on: ['turn.step.overflowed'], - maxCauseDepth: 2, // compaction 引发的再 overflow 最多续 2 层,超限自动停 - run: (fact, ctx) => fullCompaction.begin({ cause: ctx.cause }), -}); -``` - -### D.6 场景:resume / 回放 / 局部回放(相位机 + transcript view) - -```ts -// 恢复编排(原 doResume 的手动预热、resumer/hook 三重顺序契约 → 一个流程,L3) -async function resumeAgent(stream: AgentStreamImpl): Promise { - await stream.replay(); - // 内部:读既有 wire.jsonl(路径/格式/迁移链不变,§0)→ 逐条 fold 进所有 view - // (静默,无 onChange、无 Effect、无广播)→ 期间任何 commit/emit 直接抛错(W5 响声化) - await stream.markReady(); - // 触发 onReady 一次性回调:task 磁盘对账、cron 启动、goal normalize - // (原 postRestoring 窗口 / onResumeEnded hooks 全部收编于此) -} - -// transcript view:UI 历史 = fold(替代 replay builder 双通道,R2/R4) -declare module '#/stream' { - interface ViewMap { transcript: readonly TranscriptTurn[] } -} -// 局部回放:原 range/segment/frozen 机制 → fold 的参数化初始条件,只写一处 -stream.defineView('transcript', transcriptView({ range: { start: 120 } })); - -// RPC 的 resumeSession 返回值(形状兼容现 ResumeSessionResult): -const { value: replay, seq } = stream.view('transcript').get(); -return { replay, seq }; // seq 给客户端做订阅接续水位(C3) -``` - -### D.7 场景:server-v2 消费面(广播器换源 + snapshot + 写回声) - -```ts -// 广播器:原"逐 agent 订阅 record.on + onDidCreate/onDidDispose 追补"→ 一次订阅 -const sub = sessionStream.subscribe({ sinceSeq: 0 }, ({ seq, kind, event }) => { - // durable/volatile 已由注册表分类(kind),agentId/sessionId 已缝合; - // journal/epoch/backfill/resync 照旧(§0),边缘 seq 与核心逻辑 seq 单调一致 - broadcaster.dispatch(seq, kind, event); -}); - -// snapshot 路由:跨 6 服务现拼 + drain queue → 读 view 的 {value, seq}(C6/C7) -app.get('/sessions/:id/snapshot', async (req, reply) => { - const transcript = await readView(req.params.id, 'transcript'); // 冷热一致:句柄不在则离线折叠, - const activity = await readView(req.params.id, 'sessionActivity'); // 永不触发 resume/建 agent - const inFlight = await readView(req.params.id, 'inFlightTurn'); - reply.send({ as_of_seq: transcript.seq, messages: transcript.value, - status: activity.value, in_flight_turn: inFlight.value }); -}); - -// 写路由:写即 commit,commit 必在流里——路由手发 event.session.created 三遍的问题消失(C4) -app.post('/sessions/:id/todos', async (req, reply) => { - const { seq } = todoService.setTodos(req.body.todos); - reply.send({ seq }); // 客户端乐观 UI 的确认水位:收到 ≤seq 的回声即落定 -}); -``` - -### D.8 场景:TUI 消费(回放 = 冷启动 + seq 接续) - -```ts -// 今天:SessionReplayRenderer 逆向工程 LLM 上下文 + 时间近似衔接实时流(C3) -// 重写后: -const snap = await api.snapshot(sessionId); // { as_of_seq, views... } -renderTranscript(snap.messages); // 与 live 同构的结构化数据 -ws.subscribe({ sessionId, sinceSeq: snap.as_of_seq }); // 无缝接续,不丢窗口事件 - -// 乐观写: -const pending = optimisticApply(localState, input); -const { seq } = await api.setTodos(sessionId, input); -pending.confirmWhen((echo) => echo.seq >= seq); // 写回声 rebase(§4.2) -``` diff --git a/packages/agent-core-v2/docs/service-design.md b/packages/agent-core-v2/docs/service-design.md deleted file mode 100644 index 54bd3011799..00000000000 --- a/packages/agent-core-v2/docs/service-design.md +++ /dev/null @@ -1,294 +0,0 @@ -# Service Design Principles - -> First-principles guide for designing a new Service in agent-core-v2: how to pick its -> **scope**, when to **split it across scopes**, how to **call** other Services, and which -> direction dependencies should point. -> -> This complements [`docs/di.md`](di.md). `di.md` explains the DI/Scope machinery -> ("how the container works"); this doc explains the **design rules** ("where to put things -> and why"). Read `di.md` first if you have not. - ---- - -## 1. What a Service is - -Before discussing scope or calling style, define the object. - -**A Service = a bundle of state + a set of behaviors, bound to a lifetime.** - -Of these three: - -- **Behavior** is almost *free* — the same logic runs anywhere, so it does not by itself - decide a scope. -- **State** is what pins a Service to a scope. State has an **identity** (what it is keyed - by) and a **lifetime** (when it is born, when it dies). -- **Dependencies / calling style** answer a different question: **who controls whom, and who - knows whom**. - -Every principle below derives from two root questions: - -1. **What is the identity of the state it owns?** → decides the **Scope**. -2. **Who owns the decision, and who needs the result?** → decides the **calling style** and - the **dependency direction**. - ---- - -## 2. Choosing a Scope - -**First principle: Scope = the identity + lifetime of the owned state.** - -`App` / `Workspace` / `Session` / `Agent` are four tiers of identity + lifetime: - -| Scope | State identity (keyed by) | Lifetime | -|---|---|---| -| `App` | none (single global instance) | the process | -| `Workspace` | `workspaceId` | one workspace handler (materialized once per workspace, never closed — dies with the process) | -| `Session` | `sessionId` | one session | -| `Agent` | `agentId` | one agent | - -### Decision tree - -**Q1. Does it own mutable state?** - -- **No (pure behavior)** → jump to Q3. -- **Yes** → Q2. - -**Q2. What is the identity of that state?** - -- one global instance → **`App`** -- one per workspace (shared by every session of that workspace) → **`Workspace`** -- one per session → **`Session`** -- one per agent → **`Agent`** -- a mix (a global registry *and* per-instance state) → **do not put it in one Service; - split it** (see §3 Multi-Scope). - -**Q3 (stateless). What is the shortest-lived dependency it must inject?** - -A stateless Service is pulled *down* by its shortest-lived dependency: if it injects an -`Agent`-scoped Service, it cannot be `App`. Among the scopes that still satisfy every -dependency, **default to the longest-lived one** (usually `App`) to maximize reuse and -singleton sharing. Push it down only when: - -1. it must inject a shorter-lived Service (enforced by the container); or -2. you want to limit its visibility (it conceptually belongs to one agent and should not be - globally exposed). - -### The core anti-pattern (a litmus test) - -> **Do not store per-session state in a `Map` inside a `App` Service.** - -This is the tell-tale sign of "this should have been `Session`-scoped but was lazily parked -at `App`". Consequences: - -- nobody cleans the entry up when the session ends → **leak**; -- every consumer threads `sessionId` around → **loss of type safety**; -- it cannot inject `Session`/`Agent`-scoped collaborators. - -### One-sentence self-check - -> **"When this scope is disposed, should this state disappear with it?"** -> -> - Yes → the scope is right. -> - It must outlive the scope → the scope is too short; move up one tier. -> - It should be one-per-unit but is being shared → the scope is too long; move down one tier. - ---- - -## 3. Multi-Scope splitting - -**First principle: one Service owns state at exactly one identity / lifetime. If a domain -owns state at several lifetimes, split it along those lifetime boundaries — one Service per -lifetime.** - -This is not layered-architecture aesthetics; it is forced by state identity. A class that -holds both "a global registry" and "per-session instances" will either leak (the global part -keeps per-session entries alive) or get pinned to an awkward scope where it can do neither -job well. - -### The standard split: "global registry / factory" + "per-instance" - -| Tier | Role | Naming tends to | -|---|---|---| -| `App` | **global registry / catalog / factory** — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` | -| `Workspace` / `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` | - -This pattern recurs throughout the codebase and confirms the rule: - -- **`records`** — `ISessionIndex` (`App`, read model of all persisted sessions) + - `ISessionMetadata` (`Session`, this session's metadata) + `IAgentWireRecordService` (`Agent`, this - agent's record stream). -- **`config`** — `IConfigRegistry` / `IConfigService` (`App`, global config). -- **`chatProvider` / `model` / `modelRuntime`** — `IChatProviderFactory` (`App`, - protocol adapters keyed by provider type), `IModelService` (`App`, model-alias - configuration), and `IModelResolver` (`Session`, resolves the active model into a - runtime provider config plus request authorization). Provider connection - configuration lives in the sibling `provider` domain (`IProviderService`, `App`). - Generation itself is driven by `IAgentLLMRequesterService` (`Agent`) in the `llmRequester` - domain. -- **`tool`** — `IToolDefinitionRegistry` (`App`, tool-definition registry) + `IToolService` - (`Agent`, this agent's execution). - -### When to split and when not to - -- **Split** when the domain genuinely has both a global view and per-instance state. -- **Do not split** when the domain has state at only one lifetime (e.g. purely `App` like - `log` / `telemetry`; purely `Agent` like `prompt`). **Do not pre-split for symmetry.** - -### Dependency direction after the split - -The `App` Service usually plays the **factory**: it knows how to create or locate the -per-instance one. Most consumers inject the **per-instance** Service, because it serves the -current session/agent directly without threading an id. Inject the `App` factory only when -you genuinely need cross-instance management. - ---- - -## 4. Choosing a calling style - -There are three ways for one Service to make another act: a **direct call** (DI injection), -an **event**, or a **hook**. From first principles, they answer three different questions. - -**First principle: the choice depends on "who owns the decision" + "is a result needed" + -"how many consumers".** - -### What the three mechanisms mean - -| Mechanism | Nature | Coupling | Returns a value? | Consumers | -|---|---|---|---|---| -| **Direct call** | command: A tells B to do | A → B | yes | one (known) | -| **Event** | fact: A announces "X happened" | both depend only on the bus | no | zero / one / many (unknown) | -| **Veto event** (`onBefore*`) | interception: listeners adjudicate through the event object (`veto` / `allow` / `pass` / `waitUntil`), no ids and no ordering contract | both depend only on the bus | veto result (first wins) | many, unordered | -| **Hook** (`onWill` / `onDid`, `OrderedHookSlot`) | participation: observers step into an operation, in order | both depend only on the bus | can observe / veto | many, but ordered | - -Use a veto event when many domains may intercept an operation and the only outcomes are "deny / short-circuit / let through" (e.g. `toolExecutor.onBeforeExecuteTool`): the fire side collects immediate statements first, then fulfills deferred (`waitUntil`) adjudications, so a hard deny always suppresses approval round-trips. Use an ordered hook when the *sequence* between participants is itself meaningful (result pipelines like `onDidExecuteTool`, step lifecycle like `onWillBeginStep` / `onDidFinishStep`). - -### Decision tree - -**Q1. Does A need a return value from B?** - -- Yes → **direct call**. Events cannot return a value (doing request/reply over events is an - anti-pattern). - -**Q2. Is B's reaction part of A's responsibility, or B's own concern?** - -- A's responsibility *includes* B's behavior (A orchestrates B) → **direct call**. E.g. - `session` drives `agentLifecycle`; `loop` drives `llmRequester` / `toolExecutor` — that - *is* their job. -- B's reaction is B's own concern, and A is merely **stating a fact** → **event**. E.g. - `flag` reacts to `config.onDidChangeConfiguration`; `config` does not know who is listening. - -**Q3. How many consumers?** - -- exactly one, and known → **direct call**. -- zero / one / many, and the producer should not know how many → **event**. - -**Q4. Would a direct A→B call create a cycle or violate the scope direction?** - -- This is a **consequence check**, not a primary reason. Decide by Q1–Q3 first; if the - semantics already call for an event, the decoupling comes for free. Do not turn a genuine - direct call into an event just to break a cycle. - -**Q5. Is this fact part of the durable record / replay / cross-agent projection?** - -- Yes → **emit it on the wire** (`wireRecord`). This is a system-specific but strong reason: - state changes that must be recorded, replayed, or synchronized across agents have to be - projected onto the wire, not handled by a direct call alone. `permission.set_mode`, - `goal.create/update/clear`, and `plan_mode.enter/exit` are all in this category. - Note that the wire is the *durable record*, not the live notification channel: a live - context mutation appends v1 wire records (`context.append_message` / - `context.append_loop_event` / `context.undo` / `context.clear` / - `context.apply_compaction`) *and* applies them, and `contextMemory` then fires a - `context.spliced` event, which `contextSize` / `loop` / `background` / `dynamicInjector` - actually subscribe to. Those listeners react to the **event**, not the wire — the wire is - what makes the mutation replayable. - -### One-sentence rule - -> **"I am telling you to do this, and I may need the result" → direct call.** -> **"I am announcing that something happened; react if you care" → event.** -> **"I am about to do something; you may veto it, in no particular order" → veto event.** -> **"I am announcing something, and you may step in, in order, possibly to veto" → hook.** - ---- - -## 5. Dependency direction - -Two distinct layers are involved, and they differ in *hardness*: - -- **Scope direction**: short-lived → long-lived, **enforced by the container** (already - covered in [`docs/di.md`](di.md)). -- **Domain direction**: which domain may depend on which, **a matter of judgment** — the - container does not enforce it. - -### First principle: dependency direction = the direction of "needs to know" - -> **A depends on B iff A needs B's data or behavior to do its own job.** - -That is the whole rule. `prompt` depending on `turn` (as it does today) is legitimate — -the prompt needs the turn's information to be built. `loop` depending on many capabilities -is legitimate — orchestration *is* its job. - -This rule alone is not enough; add one anti-rot heuristic to keep the graph from collapsing -into a clique: - -> **Do not let a more foundational / more-reused Service come to know a more specific / -> more-upstream one.** - -Reason: reuse gets inverted — once a foundational component knows about an upstream -scenario, it can no longer be reused by other scenarios, and it will almost always create a -cycle. - -### The natural layers of this repo - -Derived from "what is more foundational", roughly (lower is depended on by higher, never the -reverse): - -1. **Root (depend on no business domain)**: `_base`, `log`, `environment`, `event`, - `telemetry`, `kaos`. -2. **Data / state**: `records`, `filestore`, `workspace`, `blobStore`, `config`. -3. **Capabilities**: `tool`, `permission`, `prompt`, `contextMemory`, `chatProvider`, - `modelRuntime`, `skill`, … -4. **Orchestrators**: `session`, `agentLifecycle`, `loop`, `turn`, `swarm`. -5. **Edge**: `gateway`, `rpc`. - -**Red lines:** - -- Layer 1 (root) **never** depends on any business domain. -- Business logic does **not** depend on layer 5 (edge) — business code should not know REST / - WebSocket exist. -- A cycle means knowledge was placed the wrong way around. Fix it (consistent with `di.md` - scenario 9): extract a third, more foundational Service, or invert the "notification" half - into an event. - -> Note: capability → orchestrator (e.g. `prompt → turn`) is **allowed and present** in this -> repo; do not treat it as a red line. The real red line is *inverted reuse* — a -> foundational / lower Service depending on a specific / upper one. - ---- - -## 6. Putting it together - -The complete checklist for a new `IXxxService`: - -1. **What does it remember, and what is the state's identity?** → pick the scope (§2). -2. **What is the shortest-lived dependency it must inject?** → the scope cannot be longer - than that. -3. **Does it own state at both a global and a per-instance lifetime?** → if yes, split it - Multi-Scope (§3). -4. **For each collaborator: am I commanding it, notifying it, or letting it participate?** - → pick the calling style (§4). -5. **Does each dependency arrow make a more foundational thing know a more specific thing?** - → if yes, invert it (§5). - ---- - -## 7. Summary - -- **Scope**: the **identity** of the state fixes the scope; do not fake per-instance state - at `App` with a `Map`. -- **Multi-Scope**: a domain with state at several lifetimes → split into "a `App` registry - + per-instance Services". -- **Calling style**: need a result / I orchestrate → direct call; stating a fact / react if - you care → event; ordered participation / may veto → hook. -- **Dependency direction**: arrows follow "needs to know", but never let a foundational layer - know an upstream one; a cycle means knowledge is placed backwards. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 6294b09f02e..783f8ce4454 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -55,7 +55,6 @@ // agentPlugin.sessionStartRefreshPending src/agent/plugin/agentPluginService.ts // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts -// agentsMdReminder.pending src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts // contextMemory src/agent/contextMemory/contextOps.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts @@ -117,6 +116,7 @@ // toolDedupe.callKeyByCallId src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.consecutiveCount src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.consecutiveKey src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.handoffPhase src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.originalCallIndex src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.stepCalls src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.syntheticCallIds src/agent/toolDedupe/toolDedupeService.ts @@ -1041,7 +1041,6 @@ export interface AgentStateSnapshot { // src/agent/agentsMdReminder/agentsMdReminderService.ts 'agentsMdReminder.cwd': string | undefined; 'agentsMdReminder.known': Set; - 'agentsMdReminder.pending': Set; 'agentsMdReminder.seeded': boolean; // src/agent/contextMemory/contextOps.ts // replayable · durable · undoable — folds: ContextAppendMessage, ContextAppendLoopEvent, ContextClear, ContextApplyCompaction @@ -1381,6 +1380,7 @@ export interface AgentStateSnapshot { readonly parentToolCallId?: string; readonly model?: string; readonly thinkingEffort?: string; + readonly stopCode?: string; readonly taskId: string; readonly description: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; @@ -1432,6 +1432,7 @@ export interface AgentStateSnapshot { readonly parentToolCallId?: string; readonly model?: string; readonly thinkingEffort?: string; + readonly stopCode?: string; readonly taskId: string; readonly description: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; @@ -1468,6 +1469,7 @@ export interface AgentStateSnapshot { 'toolDedupe.callKeyByCallId': Map; 'toolDedupe.consecutiveCount': number; 'toolDedupe.consecutiveKey': string | null; + 'toolDedupe.handoffPhase': /* HandoffPhase — packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts */ 'idle' | 'active' | 'pending' | 'done'; 'toolDedupe.originalCallIndex': Map; 'toolDedupe.stepCalls': string[]; 'toolDedupe.syntheticCallIds': Set; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index c4aef5eec49..89e6a8b9656 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -854,6 +854,7 @@ interface TurnEndedPayload { }; }; durationMs?: number; + stopReason?: string; } /** diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 2a2406ceedf..42ead234a9d 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -42,14 +42,12 @@ const AGENTS_MD_BASENAMES: ReadonlySet = new Set(AGENTS_MD_PLAIN const BASH_PARSE_OPTIONS = { timeoutMs: 20, maxNodes: 10_000 } as const; +const DISCOVERY_REMINDER_VARIANT = 'agents_md'; + export const agentsMdReminderKnownKey = defineState>( 'agentsMdReminder.known', () => new Set(), ); -export const agentsMdReminderPendingKey = defineState>( - 'agentsMdReminder.pending', - () => new Set(), -); export const agentsMdReminderCwdKey = defineState( 'agentsMdReminder.cwd', () => undefined as string | undefined, @@ -65,6 +63,10 @@ export class AgentAgentsMdReminderService { declare readonly _serviceBrand: undefined; + private readonly remindQueue = new Set(); + private readonly readRecently = new Set(); + private readonly telemetryFired = new Set(); + constructor( @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentReminderService private readonly reminder: IAgentReminderService, @@ -80,11 +82,10 @@ export class AgentAgentsMdReminderService ) { super(); this.states.contributeState(agentsMdReminderKnownKey); - this.states.contributeState(agentsMdReminderPendingKey); this.states.contributeState(agentsMdReminderCwdKey); this.states.contributeState(agentsMdReminderSeededKey); this._register( - this.reminder.register('agents_md', (context) => + this.reminder.register(DISCOVERY_REMINDER_VARIANT, (context) => this.injectReminder(context), ), ); @@ -113,9 +114,7 @@ export class AgentAgentsMdReminderService const known = new Set(this.known); for (const path of paths) known.add(normalize(path)); this.states.set(agentsMdReminderKnownKey, known); - const pending = new Set(this.pending); - for (const path of paths) pending.delete(normalize(path)); - this.states.set(agentsMdReminderPendingKey, pending); + for (const path of paths) this.remindQueue.delete(normalize(path)); this.states.set(agentsMdReminderCwdKey, cwd); this.states.set(agentsMdReminderSeededKey, true); } @@ -135,42 +134,34 @@ export class AgentAgentsMdReminderService this.markKnown( list.filter((change) => change.action === 'modified').map((change) => change.path), ); - this.markPending( - list.filter((change) => change.action === 'created').map((change) => change.path), - ); this.markDeleted( list.filter((change) => change.action === 'deleted').map((change) => change.path), ); } - private readonly claimed = new Set(); - private get known(): Set { return this.states.get(agentsMdReminderKnownKey); } - private get pending(): Set { - return this.states.get(agentsMdReminderPendingKey); - } - private get agentCwd(): string { return this.states.get(agentsMdReminderCwdKey) ?? this.sessionContext.cwd; } - private hasPath(path: string): boolean { - return this.known.has(path) || this.pending.has(path); - } - private injectReminder( context: ContextInjectionContext, ): ContextInjectionResult | undefined { + const readRecently = new Set(this.readRecently); + this.readRecently.clear(); const known = this.known; - const pending = [...this.pending].filter((path) => !known.has(path)); - if (pending.length === 0) return undefined; + const queued = [...this.remindQueue].filter( + (path) => !known.has(path) && !readRecently.has(path), + ); + this.remindQueue.clear(); + if (queued.length === 0) return undefined; const covered = context.lastDisclosure ?? []; - const fresh = pending.filter((path) => !covered.includes(path)); + const fresh = queued.filter((path) => !covered.includes(path)); if (fresh.length === 0) return undefined; - return { content: reminderText(fresh), disclosure: pending }; + return { content: reminderText(fresh), disclosure: [...covered, ...fresh] }; } private async ensureSeeded(): Promise { @@ -190,68 +181,55 @@ export class AgentAgentsMdReminderService private async probeAndRemind(ctx: ToolDidExecuteContext): Promise { if (ctx.outcome !== 'executed') return; - const discovered: string[] = []; try { await this.ensureSeeded(); const { dirs, selfKnown } = this.targetDirs(ctx); const selfKnownSet = new Set(selfKnown); + const discovered: string[] = []; for (const dir of dirs) { for (const path of await this.probeDir(dir)) { - if (this.hasPath(path) || this.claimed.has(path) || selfKnownSet.has(path)) continue; - this.claimed.add(path); + if (this.known.has(path) || this.remindQueue.has(path) || selfKnownSet.has(path)) { + continue; + } discovered.push(path); } } - if (discovered.length === 0) { - this.markKnown(selfKnown); - return; + for (const path of selfKnown) { + this.remindQueue.delete(path); + this.readRecently.add(path); } - const properties: AgentsMdReminderShownEvent = { - turn_id: ctx.turnId, - tool_name: ctx.toolCall.name, - reminded_count: discovered.length, - trace_id: ctx.trace?.traceId, - }; - this.telemetry.track2('agents_md_reminder_shown', properties); - this.markKnown(selfKnown); - this.markPending(discovered); - } catch {} finally { - for (const path of discovered) this.claimed.delete(path); - } + if (discovered.length === 0) return; + const untracked = discovered.filter((path) => !this.telemetryFired.has(path)); + if (untracked.length > 0) { + const properties: AgentsMdReminderShownEvent = { + turn_id: ctx.turnId, + tool_name: ctx.toolCall.name, + reminded_count: untracked.length, + trace_id: ctx.trace?.traceId, + }; + this.telemetry.track2('agents_md_reminder_shown', properties); + for (const path of untracked) this.telemetryFired.add(path); + } + for (const path of discovered) this.remindQueue.add(path); + } catch {} } private markKnown(paths: readonly string[]): void { if (paths.length === 0) return; const known = new Set(this.known); - const pending = new Set(this.pending); - for (const path of paths) { - known.add(path); - pending.delete(path); - } + for (const path of paths) known.add(path); this.states.set(agentsMdReminderKnownKey, known); - this.states.set(agentsMdReminderPendingKey, pending); - } - - private markPending(paths: readonly string[]): void { - if (paths.length === 0) return; - const known = this.known; - const pending = new Set(this.pending); - for (const path of paths) { - if (!known.has(path)) pending.add(path); - } - this.states.set(agentsMdReminderPendingKey, pending); } private markDeleted(paths: readonly string[]): void { if (paths.length === 0) return; const known = new Set(this.known); - const pending = new Set(this.pending); for (const path of paths) { known.delete(path); - pending.delete(path); + this.remindQueue.delete(path); + this.telemetryFired.delete(path); } this.states.set(agentsMdReminderKnownKey, known); - this.states.set(agentsMdReminderPendingKey, pending); } private targetDirs(ctx: ToolDidExecuteContext): { dirs: string[]; selfKnown: string[] } { @@ -339,7 +317,7 @@ export class AgentAgentsMdReminderService const found: string[] = []; for (const chainDir of chain) { const candidates = agentsMdCandidatePaths(chainDir); - if (candidates.every((candidate) => this.hasPath(normalize(candidate)))) continue; + if (candidates.every((candidate) => this.known.has(normalize(candidate)))) continue; for (const path of await findAgentsMdInDir(deps, chainDir)) { found.push(normalize(path)); } diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 35c20855094..6f881bf0f38 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -240,19 +240,23 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { ): Promise { signal?.throwIfAborted(); const startedAt = Date.now(); - trace.set(undefined); + const setTrace = (traceId: string | undefined): void => { + trace.set(traceId); + if (overrides.source?.type === 'turn') { + this.telemetry.setContext({ trace_id: traceId }); + } + }; + setTrace(undefined); try { return await this.runRequest( this.resolveRequest(overrides), onPart, signal, - (traceId) => { - trace.set(traceId); - }, + setTrace, ); } catch (error) { this.logRequestFailure(error, overrides, signal); - trace.set(this.trackApiError(error, startedAt, signal, overrides.source, trace.traceId)); + setTrace(this.trackApiError(error, startedAt, signal, overrides.source, trace.traceId)); throw error; } } diff --git a/packages/agent-core-v2/src/agent/loop/handoffStep.ts b/packages/agent-core-v2/src/agent/loop/handoffStep.ts new file mode 100644 index 00000000000..eb27197dc21 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/handoffStep.ts @@ -0,0 +1,24 @@ +import { ContinuationStepRequest } from './stepRequest'; + +export const HANDOFF_STEP_KIND = 'handoff'; + +export interface HandoffStepObserver { + onMaterialize(): void; + onAbort(): void; +} + +export class HandoffStepRequest extends ContinuationStepRequest { + constructor(private readonly observer: HandoffStepObserver) { + super({ kind: HANDOFF_STEP_KIND }); + } + + override onWillMaterialize(): void { + this.observer.onMaterialize(); + } + + override abort(): boolean { + const aborted = super.abort(); + if (aborted) this.observer.onAbort(); + return aborted; + } +} diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index 3fcc68993c9..733686675d0 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -75,6 +75,7 @@ export type LoopRunResult = readonly type: 'completed'; readonly steps: number; readonly truncated: boolean; + readonly stopReason?: string; } | { readonly type: 'failed'; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 3c7ad890753..09caa223074 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -24,7 +24,6 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import type { TurnEndedEvent as TurnEndedTelemetryEvent, TurnInterruptedEvent, @@ -56,6 +55,7 @@ import { type TurnSeed, } from './stepRequest'; import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue'; +import { HANDOFF_STEP_KIND } from './handoffStep'; import { AssistantDelta, isDisplayablePromptOrigin, @@ -111,7 +111,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); @@ -492,22 +491,20 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ready: ReturnType>, ): Promise { const startedAt = Date.now(); - this.telemetryContext.set({ turn_id: turn.id }); - const telemetryContext = this.telemetryContext.get(); - const turnTelemetry = this.telemetry.withContext(telemetryContext); - const { mode, provider_type, protocol } = telemetryContext; + this.telemetry.setContext({ turn_id: turn.id }); + const { mode, provider_type, protocol } = this.telemetry.getContext(); let thinkingEffort: string | undefined; let result: TurnResult | undefined; try { thinkingEffort = this.llmRequester.prepareTurnConfig(turn.id)?.thinkingEffort; + this.telemetry.setContext({ thinking_effort: thinkingEffort }); const started: TurnStartedTelemetryEvent = { turn_id: turn.id, - mode, + mode: mode ?? 'agent', provider_type, protocol, - thinking_effort: thinkingEffort, }; - turnTelemetry.track2('turn_started', started); + this.telemetry.track2('turn_started', started); result = await this.run({ turnId: turn.id, signal: turn.signal, @@ -537,6 +534,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { error, durationMs, interruptReason, + stopReason: result.type === 'completed' ? result.stopReason : undefined, }), ); if (error !== undefined) { @@ -548,27 +546,26 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const interrupted: TurnInterruptedEvent = { turn_id: turn.id, at_step: result.steps, - mode, + mode: mode ?? 'agent', interrupt_reason: interruptReason, provider_type, protocol, - thinking_effort: thinkingEffort, trace_id: traceId, }; - turnTelemetry.track2('turn_interrupted', interrupted); + this.telemetry.track2('turn_interrupted', interrupted); } } const ended: TurnEndedTelemetryEvent = { turn_id: turn.id, reason: result?.type ?? 'failed', duration_ms: Date.now() - startedAt, - mode, + mode: mode ?? 'agent', provider_type, protocol, - thinking_effort: thinkingEffort, trace_id: traceId, }; - turnTelemetry.track2('turn_ended', ended); + this.telemetry.track2('turn_ended', ended); + this.telemetry.setContext({ turn_id: undefined, trace_id: undefined, thinking_effort: undefined }); this.activeRequestTrace = undefined; this.lastRequestTraceId = undefined; this.pumpTurns(); @@ -675,24 +672,37 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { queue: job?.queue ?? this.standaloneStepQueue, steps: 0, lastStopReason: undefined, + forcedStopReason: undefined, current: undefined, }; } + private completedResult(runtime: LoopRuntime): LoopRunResult { + const truncated = runtime.lastStopReason === 'truncated'; + if (runtime.forcedStopReason === undefined) { + return { type: 'completed', steps: runtime.steps, truncated }; + } + return { + type: 'completed', + steps: runtime.steps, + truncated, + stopReason: runtime.forcedStopReason, + }; + } + private beginLoopStep(runtime: LoopRuntime): BeginStepResult { runtime.current = undefined; runtime.turnSignal.throwIfAborted(); if (!runtime.queue.hasPendingRequests()) { - return { - result: { - type: 'completed', - steps: runtime.steps, - truncated: runtime.lastStopReason === 'truncated', - }, - }; + return { result: this.completedResult(runtime) }; } const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; - if (maxSteps !== undefined && maxSteps > 0 && runtime.steps >= maxSteps) { + if ( + maxSteps !== undefined && + maxSteps > 0 && + runtime.steps >= maxSteps && + runtime.queue.peekDriverKind() !== HANDOFF_STEP_KIND + ) { throw createMaxStepsExceededError(maxSteps); } const batch = runtime.queue.takeNextBatch()!; @@ -727,6 +737,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } runtime.current = undefined; runtime.lastStopReason = result.stopReason; + if (result.stopTurnReason !== undefined && runtime.forcedStopReason === undefined) { + runtime.forcedStopReason = result.stopTurnReason; + } if (result.stopReason === 'filtered') { throw new Error2(ErrorCodes.PROVIDER_FILTERED, 'Provider safety policy blocked the response.', { name: 'ProviderFilteredError', @@ -734,7 +747,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { }); } if (!result.hookStopTurn) return undefined; - return { type: 'completed', steps: runtime.steps, truncated: result.stopReason === 'truncated' }; + return this.completedResult(runtime); } private async handleLoopStepError( @@ -841,6 +854,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { onStarted: ((step: number) => void) | undefined, ): Promise { this.activeRequestTrace = undefined; + this.telemetry.setContext({ trace_id: undefined }); await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, firstStepOfTurn, signal }); const markStepStarted = this.beginStep(turnId, signal, currentStep, stepUuid, onStarted); let stepEndAppended = false; @@ -861,7 +875,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } this.lastRequestTraceId = request.trace.traceId; this.appendResponseContent(turnId, currentStep, stepUuid, response); - const finishReason = await this.executeStepTools( + const { finishReason, stopTurnReason } = await this.executeStepTools( turnId, signal, currentStep, @@ -879,7 +893,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { response.usage, finishReason, ); - return { stopReason: finishReason, hookStopTurn }; + return { stopReason: finishReason, hookStopTurn, stopTurnReason }; } catch (error) { if (!stepEndAppended) { this.context.appendLoopEvent({ @@ -968,13 +982,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { stepUuid: string, response: AgentLLMRequestFinish, trace: LLMRequestTrace, - ): Promise { + ): Promise { let finishReason = response.providerFinishReason ?? 'completed'; if (response.message.toolCalls.length === 0) { - return finishReason === 'tool_calls' ? 'other' : finishReason; + return { finishReason: finishReason === 'tool_calls' ? 'other' : finishReason }; } const toolCallUuids = new Map(); let stopTurn = false; + let stopTurnReason: string | undefined; for await (const toolResult of this.toolExecutor.execute(response.message.toolCalls, { signal, turnId, @@ -1003,10 +1018,13 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { toolCallId: toolResult.toolCallId, result: { output: result.output, isError: result.isError, note: result.note }, }); - if (result.stopTurn === true) stopTurn = true; + if (result.stopTurn === true) { + stopTurn = true; + stopTurnReason ??= result.stopTurnReason; + } } finishReason = stopTurn ? 'completed' : 'tool_calls'; - return finishReason; + return { finishReason, stopTurnReason }; } private finishStep( @@ -1239,6 +1257,7 @@ interface LoopRuntime { readonly queue: StepRequestQueue; steps: number; lastStopReason: FinishReason | undefined; + forcedStopReason: string | undefined; current: StepRuntime | undefined; } @@ -1277,6 +1296,12 @@ function interruptReasonFor( type StepExecutionResult = { readonly stopReason: FinishReason; readonly hookStopTurn: boolean; + readonly stopTurnReason?: string; +}; + +type StepToolsOutcome = { + readonly finishReason: FinishReason; + readonly stopTurnReason?: string; }; type LoopErrorDisposition = diff --git a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts index b8aa03ae409..42721ddb8e1 100644 --- a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts +++ b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts @@ -20,6 +20,12 @@ export class StepRequestQueue { return this.items.some((item) => !item.aborted); } + peekDriverKind(): string | undefined { + this.discardAborted(); + const driver = this.items.find((item) => !item.mergeable) ?? this.items[0]; + return driver?.kind; + } + takeNextBatch(): StepRequestBatch | undefined { this.discardAborted(); if (this.items.length === 0) return undefined; diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 95029d642a7..5f511599058 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -91,6 +91,7 @@ const turnEndedSchema = z.object({ reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), error: z.custom().optional(), durationMs: z.number().optional(), + stopReason: z.string().optional(), }); export interface TurnEndedPayload { @@ -100,6 +101,7 @@ export interface TurnEndedPayload { readonly error?: KimiErrorPayload; readonly durationMs?: number; readonly interruptReason?: TurnInterruptReason; + readonly stopReason?: string; } export class TurnEnded extends AgentEvent2 { @@ -117,6 +119,7 @@ export class TurnEnded extends AgentEvent2 { }; if (this.error !== undefined) record['error'] = this.error; if (this.durationMs !== undefined) record['durationMs'] = this.durationMs; + if (this.stopReason !== undefined) record['stopReason'] = this.stopReason; record['time'] = this.time; return record as SerializedEvent2; } diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index dcf44180da2..6fab8566293 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -139,10 +139,8 @@ export async function mcpResultToExecutableOutput( } const compressed = await compressImageContentParts(wrapped, { - telemetry: - options.telemetry === undefined - ? undefined - : { client: options.telemetry, source: 'mcp_tool_result' }, + telemetry: options.telemetry, + telemetrySource: 'mcp_tool_result', annotate: { persistOriginal: (bytes, mimeType) => persistOriginalImage( diff --git a/packages/agent-core-v2/src/agent/media/image-compress.ts b/packages/agent-core-v2/src/agent/media/image-compress.ts index 9622de62395..4d751ed1020 100644 --- a/packages/agent-core-v2/src/agent/media/image-compress.ts +++ b/packages/agent-core-v2/src/agent/media/image-compress.ts @@ -1,4 +1,6 @@ import type { ContentPart } from '#/kosong/contract/message'; +import type { ImageCompressEvent, ImageCropEvent } from '#/app/telemetry/events'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; import { sniffImageDimensions } from './file-type'; import { @@ -60,19 +62,8 @@ export interface CompressImageOptions { readonly maxEdge?: number; readonly byteBudget?: number; readonly maxDecodeBytes?: number; - readonly telemetry?: ImageCompressionTelemetry; -} - -export interface ImageCompressionTelemetryClient { - track( - event: string, - properties?: Readonly>, - ): void; -} - -export interface ImageCompressionTelemetry { - readonly client: ImageCompressionTelemetryClient; - readonly source: string; + readonly telemetry?: ITelemetryService; + readonly telemetrySource?: string; } type CompressOutcome = @@ -119,7 +110,7 @@ export async function compressImageForModel( finalByteLength: bytes.length, }); const finish = (outcome: CompressOutcome, result: CompressImageResult): CompressImageResult => { - reportCompressEvent(options.telemetry, { + reportCompressEvent(options.telemetry, options.telemetrySource, { outcome, startedAt, inputMime: normalizedMime, @@ -215,7 +206,7 @@ export async function compressBase64ForModel( originalByteLength: approxBytes, finalByteLength: approxBytes, }; - reportCompressEvent(options.telemetry, { + reportCompressEvent(options.telemetry, options.telemetrySource, { outcome: 'passthrough_guard', startedAt, inputMime: normalizeImageMime(mimeType), @@ -239,7 +230,7 @@ export async function compressBase64ForModel( originalByteLength: 0, finalByteLength: 0, }; - reportCompressEvent(options.telemetry, { + reportCompressEvent(options.telemetry, options.telemetrySource, { outcome: 'passthrough_error', startedAt, inputMime: normalizeImageMime(mimeType), @@ -425,11 +416,11 @@ export async function cropImageForModel( const normalizedMime = normalizeImageMime(mimeType); const fail = (errorKind: CropErrorKind, error: string): CropImageFailure => { - reportCropEvent(options.telemetry, { startedAt, ok: false, errorKind }); + reportCropEvent(options.telemetry, options.telemetrySource, { startedAt, ok: false, errorKind }); return { ok: false, error }; }; const succeed = (result: CropImageSuccess): CropImageSuccess => { - reportCropEvent(options.telemetry, { startedAt, ok: true, result }); + reportCropEvent(options.telemetry, options.telemetrySource, { startedAt, ok: true, result }); return result; }; @@ -719,7 +710,8 @@ interface CompressEventResult { } function reportCompressEvent( - telemetry: ImageCompressionTelemetry | undefined, + telemetry: ITelemetryService | undefined, + source: string | undefined, input: { readonly outcome: CompressOutcome; readonly startedAt: number; @@ -728,10 +720,10 @@ function reportCompressEvent( readonly result: CompressEventResult; }, ): void { - if (telemetry === undefined) return; + if (telemetry === undefined || source === undefined) return; try { - telemetry.client.track('image_compress', { - source: telemetry.source, + const event: ImageCompressEvent = { + source, outcome: input.outcome, input_mime: input.inputMime, output_mime: normalizeImageMime(input.result.mimeType), @@ -743,13 +735,15 @@ function reportCompressEvent( final_height: input.result.height, exif_transposed: input.exifTransposed, duration_ms: Date.now() - input.startedAt, - }); + }; + telemetry.track2('image_compress', event); } catch { } } function reportCropEvent( - telemetry: ImageCompressionTelemetry | undefined, + telemetry: ITelemetryService | undefined, + source: string | undefined, input: { readonly startedAt: number; readonly ok: boolean; @@ -757,13 +751,13 @@ function reportCropEvent( readonly result?: CropImageSuccess; }, ): void { - if (telemetry === undefined) return; + if (telemetry === undefined || source === undefined) return; try { const { result } = input; const originalPixels = result === undefined ? 0 : result.originalWidth * result.originalHeight; - telemetry.client.track('image_crop', { - source: telemetry.source, + const event: ImageCropEvent = { + source, ok: input.ok, error_kind: input.errorKind, resized: result?.resized, @@ -775,7 +769,8 @@ function reportCropEvent( : (result.region.width * result.region.height) / originalPixels, final_bytes: result?.finalByteLength, duration_ms: Date.now() - input.startedAt, - }); + }; + telemetry.track2('image_crop', event); } catch { } } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 7c1c67b4c9f..03888198600 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -43,7 +43,6 @@ import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { extractAgentsMdPathsFromSystemPrompt, @@ -153,7 +152,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ constructor( @IEventDispatcher private readonly dispatcher: IEventDispatcher, @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IConfigService private readonly config: IConfigService, @IModelCatalog private readonly modelCatalog: IModelCatalog, @IProtocolAdapterRegistry private readonly protocolAdapters: IProtocolAdapterRegistry, @@ -183,6 +181,12 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.states.contributeState(profileEmittedToolPatternWarningsKey); this.states.contributeState(profileEmittedPluginBudgetWarningsKey); this.configure({}); + this._register( + this.dispatcher.hooks.onDidRestore.register('profile', async (_ctx, next) => { + this.syncTelemetryModelContext(this.modelAlias); + await next(); + }), + ); this._register( this.config.onDidSectionChange(({ domain }) => { if (domain === TOOLS_SECTION) { @@ -626,11 +630,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private afterConfigDispatch(changed: Omit): void { if (changed.modelAlias !== undefined) { - const model = this.tryResolveRawModel(); - this.telemetryContext.set({ - provider_type: model?.providerType ?? model?.protocol, - protocol: model?.protocol, - }); + this.syncTelemetryModelContext(changed.modelAlias); } if (changed.modelAlias !== undefined || changed.thinkingLevel !== undefined) { this.warnAboutAnthropicThinkingEffort(); @@ -640,6 +640,18 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); } + private syncTelemetryModelContext(modelAlias: string | undefined): void { + if (modelAlias === undefined) { + return; + } + const model = this.tryResolveRawModel(); + this.telemetry.setContext({ + model: modelAlias, + provider_type: model?.providerType ?? model?.protocol, + protocol: model?.protocol, + }); + } + private warnAboutAnthropicThinkingEffort(): void { try { const model = this.tryResolveRawModel(); diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts index 5763f4e21d1..865548a8139 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts @@ -15,6 +15,8 @@ export interface ToolDedupeErrorResult extends ExecutableToolErrorResult { export type ToolDedupeResult = ToolDedupeSuccessResult | ToolDedupeErrorResult; +export const REPEAT_BREAKER_STOP_REASON = 'repeat_breaker'; + export interface IAgentToolDedupeService { readonly _serviceBrand: undefined; } diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 85ce0885885..5605ca6761f 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -8,19 +8,25 @@ import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args'; import type { ToolCallDedupDetectedEvent, ToolCallRepeatEvent, + ToolCallRepeatHandoffEvent, ToolCallTurnRepeatEvent, } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { HandoffStepRequest } from '#/agent/loop/handoffStep'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; import { TurnEnded } from '#/agent/loop/turnOps'; import { wrapSystemReminder } from '#/features/reminder/systemReminder'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/kosong/contract/message'; -import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; +import { + IAgentToolDedupeService, + REPEAT_BREAKER_STOP_REASON, + type ToolDedupeResult, +} from './toolDedupe'; const REMINDER_TEXT_1 = '\n\n' + @@ -56,6 +62,21 @@ const REPEAT_REMINDER_2_START = 5; const REPEAT_REMINDER_3_START = 8; const REPEAT_FORCE_STOP_STREAK = 12; +const HANDOFF_VETO_TEXT = + 'This turn was ended by the repeat breaker after the same tool call was issued ' + + `${String(REPEAT_FORCE_STOP_STREAK)} times in a row. This step accepts a text response only, ` + + 'so the tool call was not executed. Reply in text: the current blocker, what you tried, ' + + 'and what you need next.'; + +const HANDOFF_VETO_RESULT: ToolDedupeResult = { + output: HANDOFF_VETO_TEXT, + isError: true, + stopTurn: true, + stopTurnReason: REPEAT_BREAKER_STOP_REASON, +}; + +type HandoffPhase = 'idle' | 'pending' | 'active' | 'done'; + interface Deferred { readonly promise: Promise; resolve(value: T): void; @@ -116,7 +137,7 @@ function appendReminder(result: ToolDedupeResult, reminderText: string): ToolDed function forceStopResult(result: ToolDedupeResult, reminderText: string): ToolDedupeResult { const withReminder = appendReminder(result, reminderText); - return { ...withReminder, stopTurn: true }; + return { ...withReminder, stopTurn: true, stopTurnReason: REPEAT_BREAKER_STOP_REASON }; } const DEDUPE_PLACEHOLDER_RESULT: ToolDedupeResult = { output: '' }; @@ -155,14 +176,20 @@ export const toolDedupeTurnRepeatCountKey = defineState( 'toolDedupe.turnRepeatCount', () => 0, ); +export const toolDedupeHandoffPhaseKey = defineState( + 'toolDedupe.handoffPhase', + () => 'idle' as HandoffPhase, +); export class AgentToolDedupeService extends Service implements IAgentToolDedupeService { declare readonly _serviceBrand: undefined; private readonly stepDeferreds = new Map>(); + private readonly handoffVetoedCallIds = new Set(); + private forceStoppedInStep = false; constructor( @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentLoopService loop: IAgentLoopService, + @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, @IAgentStateService private readonly states: IAgentStateService, @IEventBus eventBus: IEventBus, @@ -178,16 +205,23 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS this.states.contributeState(toolDedupeActiveStepKey); this.states.contributeState(toolDedupeTurnCallRecordsKey); this.states.contributeState(toolDedupeTurnRepeatCountKey); + this.states.contributeState(toolDedupeHandoffPhaseKey); this._register(eventBus.subscribe(TurnEnded, () => this.clearTurnRecords())); loop.hooks.onWillBeginStep.register('toolDedupe', async (ctx, next) => { this.beginStep(ctx.turnId, ctx.step); await next(); }); - loop.hooks.onDidFinishStep.register('toolDedupe', async (_ctx, next) => { + loop.hooks.onDidFinishStep.register('toolDedupe', async (ctx, next) => { this.endStep(); + this.settleHandoff(ctx.turnId); await next(); }); toolExecutor.onBeforeExecuteTool((event) => { + if (this.handoffPhase === 'active') { + this.handoffVetoedCallIds.add(event.toolCall.id); + event.veto(HANDOFF_VETO_RESULT); + return; + } const checked = this.checkToolCall( event.toolCall.id, event.toolCall.name, @@ -199,6 +233,13 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS } }); toolExecutor.hooks.onDidExecuteTool.register('toolDedupe', async (ctx, next) => { + if (this.handoffPhase === 'active') { + this.handoffVetoedCallIds.add(ctx.toolCall.id); + ctx.result = HANDOFF_VETO_RESULT; + ctx.stopTurn = true; + await next(); + return; + } this.registerSkipped( ctx.toolCall.id, ctx.toolCall.name, @@ -284,6 +325,14 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS this.states.set(toolDedupeTurnRepeatCountKey, value); } + private get handoffPhase(): HandoffPhase { + return this.states.get(toolDedupeHandoffPhaseKey); + } + + private set handoffPhase(value: HandoffPhase) { + this.states.set(toolDedupeHandoffPhaseKey, value); + } + private clearTurnRecords(): void { this.turnCallRecords.clear(); this.turnRepeatCount = 0; @@ -294,11 +343,14 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS this.activeTurnId = turnId; this.consecutiveKey = null; this.consecutiveCount = 0; + this.handoffPhase = 'idle'; this.clearTurnRecords(); } if (step !== undefined) { this.activeStep = step; } + this.forceStoppedInStep = false; + this.handoffVetoedCallIds.clear(); for (const deferred of this.stepDeferreds.values()) { deferred.resolve({ @@ -324,6 +376,31 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS } } + private settleHandoff(turnId: number): void { + const phase = this.handoffPhase; + if (phase === 'active') { + this.handoffPhase = 'done'; + const properties: ToolCallRepeatHandoffEvent = { + turn_id: turnId, + outcome: this.handoffVetoedCallIds.size > 0 ? 'vetoed' : 'text', + }; + this.telemetry.track2('tool_call_repeat_handoff', properties); + return; + } + if (phase !== 'idle' || !this.forceStoppedInStep) return; + this.handoffPhase = 'pending'; + this.loop.enqueue( + new HandoffStepRequest({ + onMaterialize: () => { + this.handoffPhase = 'active'; + }, + onAbort: () => { + this.handoffPhase = 'done'; + }, + }), + ); + } + private recordTurnRepeat( toolCallId: string, toolName: string, @@ -455,6 +532,7 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS if (streak >= REPEAT_FORCE_STOP_STREAK) { finalResult = forceStopResult(result, REMINDER_TEXT_3); action = 'stop'; + this.forceStoppedInStep = true; } else if (streak >= REPEAT_REMINDER_3_START) { finalResult = appendReminder(result, REMINDER_TEXT_3); action = 'r3'; @@ -490,6 +568,8 @@ export const __testing = { REPEAT_REMINDER_2_START, REPEAT_REMINDER_3_START, REPEAT_FORCE_STOP_STREAK, + REPEAT_BREAKER_STOP_REASON, + HANDOFF_VETO_TEXT, }; registerScopedService( diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 47fc56a48cf..9f144d187f6 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -883,6 +883,7 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { const base: { output: ToolResult['output']; stopTurn?: boolean; + stopTurnReason?: string; truncated?: true; note?: string; spill?: ToolResultSpill; @@ -893,6 +894,7 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { spill: result.spill, spillExempt: result.spillExempt, }; + if (result.stopTurnReason !== undefined) base.stopTurnReason = result.stopTurnReason; if (result.truncated === true) base.truncated = true; if (typeof result.note === 'string' && result.note.length > 0) base.note = result.note; if (result.isError === true) { 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 1eedfdf1505..159dbf48a9c 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -6,6 +6,8 @@ import { userCancellationReason, } from '#/_base/utils/abort'; import { Error2, ErrorCodes, isError2 } from '#/errors'; +import { REPEAT_BREAKER_STOP_REASON } from '#/agent/toolDedupe/toolDedupe'; +import type { AgentTaskInfo } from '#/agent/task/types'; import { toInputJsonSchema } from '#/tool/input-schema'; import { matchesGlobRuleSubject } from '#/tool/rule-match'; import { @@ -20,6 +22,7 @@ import { import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { ToolAccesses, type ExecutableToolContext, @@ -39,11 +42,18 @@ import { withoutDelegatingTargets, } from '#/app/agentProfileCatalog/profile-shared'; import { ILogService } from '#/_base/log/log'; +import { hasPinnedPermissionMode } from '#/features/tower/tower'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { isSubagentMeta, subagentLabels, subagentParentAgentId } from '#/session/agentLifecycle/subagentMetadata'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { + isSubagentMeta, + labelsFromAgentMeta, + subagentLabels, + subagentParentAgentId, + subagentProfileName, +} from '#/session/agentLifecycle/subagentMetadata'; +import { type AgentMeta, ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { emitAgentRunSpawned, mirrorAgentRun, SubagentStarted } from '#/session/subagent/mirrorAgentRun'; import { IEventDispatcher } from '#/state/eventDispatcher'; @@ -107,6 +117,7 @@ export class SubagentTool implements ISubagentTool { @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @ILogService private readonly log: ILogService, @IConfigService private readonly config: IConfigService, @@ -230,7 +241,7 @@ export class SubagentTool implements ISubagentTool { const profileNameForDisplay = resumeAgentId !== undefined && resumeAgentId.length > 0 - ? this.resumeProfileName(resumeAgentId) ?? RESUMED_LABEL + ? (await this.resumeProfileName(resumeAgentId)) ?? RESUMED_LABEL : (requestedProfileName ?? (args.fork === true ? (this.profile.data().profileName ?? DEFAULT_PROFILE_NAME) @@ -251,10 +262,10 @@ export class SubagentTool implements ISubagentTool { }; } - private resumeProfileName(agentId: string): string | undefined { + private async resumeProfileName(agentId: string): Promise { const target = this.agentLifecycle.handleOf(agentId); - if (target === undefined) return undefined; - return target.accessor.get(IAgentProfileService).data().profileName; + if (target !== undefined) return target.accessor.get(IAgentProfileService).data().profileName; + return subagentProfileName((await this.sessionMetadata.read()).agents?.[agentId]); } private async launch( @@ -280,13 +291,7 @@ export class SubagentTool implements ISubagentTool { let displayModelSource: SubagentModelSource | undefined; let promptText = args.prompt; if (isResume) { - const target = this.agentLifecycle.handleOf(resumeAgentId); - if (target === undefined) { - throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${resumeAgentId}" does not exist`, { - details: { agentId: resumeAgentId }, - }); - } - await this.ensureOwnedIdleSubagent(resumeAgentId, target); + const target = await this.resolveResumeTarget(resumeAgentId); agentId = target.id; const resumed = target.accessor.get(IAgentProfileService).data(); profileName = resumed.profileName ?? RESUMED_LABEL; @@ -336,16 +341,23 @@ export class SubagentTool implements ISubagentTool { thinkingEffort: this.agentLifecycle.handleOf(agentId) ?.accessor.get(IAgentProfileService) .getEffectiveThinkingLevel(), - completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })), + completion: mirrored.then((r) => ({ + result: r.summary, + usage: r.usage, + stopReason: r.stopReason, + })), }; } - private async ensureOwnedIdleSubagent( - agentId: string, - target: IAgentScopeHandle, - ): Promise { + private async resolveResumeTarget(agentId: string): Promise { + const live = this.agentLifecycle.handleOf(agentId); const meta = (await this.sessionMetadata.read()).agents?.[agentId]; - if (!isSubagentMeta(meta)) { + if (meta === undefined && live === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, { + details: { agentId }, + }); + } + if (meta === undefined || !isSubagentMeta(meta)) { throw new Error2(ErrorCodes.AGENT_NOT_A_SUBAGENT, `Agent instance "${agentId}" is not a subagent`, { details: { agentId }, }); @@ -357,6 +369,7 @@ export class SubagentTool implements ISubagentTool { { details: { agentId, callerAgentId: this.callerAgentId } }, ); } + const target = live ?? (await this.rebuildSubagent(agentId, meta)); if (target.accessor.get(IAgentLoopService).status().state === 'running') { throw new Error2( ErrorCodes.AGENT_ALREADY_RUNNING, @@ -364,6 +377,26 @@ export class SubagentTool implements ISubagentTool { { details: { agentId } }, ); } + return target; + } + + private async rebuildSubagent(agentId: string, meta: AgentMeta): Promise { + await this.agentLifecycle.create({ + agentId, + labels: labelsFromAgentMeta(meta), + forkedFrom: meta.forkedFrom, + }); + const rebuilt = this.agentLifecycle.handleOf(agentId); + if (rebuilt === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, { + details: { agentId }, + }); + } + if (!hasPinnedPermissionMode(rebuilt.accessor.get(IAgentProfileService).data().profileName)) { + rebuilt.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); + } + this.log.info('subagent rebuilt for resume', { agentId, callerAgentId: this.callerAgentId }); + return rebuilt; } private async execution( @@ -494,9 +527,10 @@ export class SubagentTool implements ISubagentTool { timeoutMs: number, ): Promise { const info = this.tasks.getTask(taskId); + const stopCode = info?.kind === 'agent' ? info.stopCode : undefined; if (info?.status === 'completed') { return { - output: formatForegroundAgentSuccess(handle, await this.tasks.readOutput(taskId)), + output: formatForegroundAgentSuccess(handle, await this.tasks.readOutput(taskId), stopCode), }; } const timedOut = info?.status === 'timed_out'; @@ -504,12 +538,87 @@ export class SubagentTool implements ISubagentTool { ? `Agent timed out after ${formatSubagentTimeoutDescription(timeoutMs)}.` : formatSubagentStoppedMessage(info?.stopReason); return { - output: formatForegroundAgentFailure(handle, message, timedOut), + output: formatForegroundAgentFailure(handle, message, failureStopReason(info, stopCode)), isError: true, }; } } +type SubagentStopReason = + | 'completed' + | 'repeat_breaker' + | 'max_tokens' + | 'max_steps' + | 'filtered' + | 'provider_error' + | 'no_final_message' + | 'cancelled' + | 'stopped' + | 'timed_out' + | 'error'; + +const REASON_MAX_CHARS = 2000; + +const REPEAT_BREAKER_NOTICE = + 'notice: The subagent was stopped by the repeat breaker after issuing the same tool call repeatedly. The summary below is its handoff, not a finished result.'; + +function resumeHint(agentId: string, prompt: string): string { + return `resume_hint: Continue with Agent(resume="${agentId}", prompt="${prompt}"). Use agent_id only; do not set subagent_type. The subagent retains its prior context; redo any unfinished tool call if its result was lost.`; +} + +const RESUME_NEXT_STEP = + 'next_step: Resume to continue where it stopped, or take over the task yourself; if neither works, report the failure to the user.'; + +const NEXT_STEP_BY_REASON: Readonly> = { + completed: undefined, + repeat_breaker: + 'next_step: The subagent was stuck on one tool call. If you resume it, change the instructions or supply the missing input; otherwise continue the work yourself.', + cancelled: 'next_step: The user stopped this subagent. Do not restart it unless the user asks.', + filtered: + 'next_step: Resuming is unlikely to help; rephrase or split the task before trying again.', + max_tokens: RESUME_NEXT_STEP, + max_steps: RESUME_NEXT_STEP, + provider_error: RESUME_NEXT_STEP, + no_final_message: RESUME_NEXT_STEP, + stopped: RESUME_NEXT_STEP, + timed_out: RESUME_NEXT_STEP, + error: RESUME_NEXT_STEP, +}; + +const STOP_REASON_BY_CODE: Readonly> = { + [REPEAT_BREAKER_STOP_REASON]: 'repeat_breaker', + [ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED]: 'max_tokens', + [ErrorCodes.LOOP_MAX_STEPS_EXCEEDED]: 'max_steps', + [ErrorCodes.PROVIDER_FILTERED]: 'filtered', + [ErrorCodes.PROVIDER_RATE_LIMIT]: 'provider_error', + [ErrorCodes.PROVIDER_API_ERROR]: 'provider_error', + [ErrorCodes.PROVIDER_OVERLOADED]: 'provider_error', + [ErrorCodes.PROVIDER_CONNECTION_ERROR]: 'provider_error', + [ErrorCodes.PROVIDER_AUTH_ERROR]: 'provider_error', + [ErrorCodes.AGENT_NO_FINAL_MESSAGE]: 'no_final_message', +}; + +function nextStep(reason: SubagentStopReason): string | undefined { + return NEXT_STEP_BY_REASON[reason]; +} + +function failureStopReason( + info: AgentTaskInfo | undefined, + stopCode: string | undefined, +): SubagentStopReason { + if (info?.status === 'timed_out') return 'timed_out'; + if (info?.status === 'killed') { + return info.stopReason?.trim() === userCancellationReason().message ? 'cancelled' : 'stopped'; + } + if (stopCode === undefined) return 'error'; + return STOP_REASON_BY_CODE[stopCode] ?? 'error'; +} + +function truncateReason(reason: string): string { + if (reason.length <= REASON_MAX_CHARS) return reason; + return `${reason.slice(0, REASON_MAX_CHARS)}… [truncated]`; +} + registerAgentToolService(ISubagentTool, SubagentTool, { name: 'Agent', domain: 'subagent', @@ -584,34 +693,42 @@ function formatBackgroundAgentResult( ].join('\n'); } -function formatForegroundAgentSuccess(handle: SubagentHandle, result: string): string { - return [ +function formatForegroundAgentSuccess( + handle: SubagentHandle, + result: string, + stopCode: string | undefined, +): string { + const reason: SubagentStopReason = + stopCode === REPEAT_BREAKER_STOP_REASON ? 'repeat_breaker' : 'completed'; + const lines = [ `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, 'status: completed', - '', - '[summary]', - result, - ].join('\n'); + `stop_reason: ${reason}`, + ]; + if (reason === 'repeat_breaker') lines.push(REPEAT_BREAKER_NOTICE); + lines.push('', '[summary]', result, '', resumeHint(handle.agentId, '...')); + const next = nextStep(reason); + if (next !== undefined) lines.push(next); + return lines.join('\n'); } function formatForegroundAgentFailure( handle: SubagentHandle, message: string, - timedOut: boolean, + reason: SubagentStopReason, ): string { const lines = [ `agent_id: ${handle.agentId}`, `actual_subagent_type: ${handle.profileName}`, 'status: failed', + `stop_reason: ${reason}`, '', `subagent error: ${message}`, ]; - if (timedOut) { - lines.push( - `resume_hint: Continue with Agent(resume="${handle.agentId}", prompt="continue"). Use agent_id only; do not set subagent_type. The subagent retains its prior context; redo any unfinished tool call if its result was lost.`, - ); - } + if (reason !== 'cancelled') lines.push(resumeHint(handle.agentId, 'continue')); + const next = nextStep(reason); + if (next !== undefined) lines.push(next); return lines.join('\n'); } @@ -625,7 +742,7 @@ function formatSubagentStoppedMessage(reason: string | undefined): string { const normalized = reason?.trim(); if (normalized === userCancellationReason().message) return USER_INTERRUPTED_SUBAGENT_MESSAGE; if (normalized === undefined || normalized.length === 0) return SUBAGENT_STOPPED_MESSAGE; - return `${SUBAGENT_STOPPED_MESSAGE} Reason: ${normalized}`; + return `${SUBAGENT_STOPPED_MESSAGE} Reason: ${truncateReason(normalized)}`; } function errorMessage(error: unknown): string | undefined { diff --git a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts index 4bc915a9b75..886a6b0c01e 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts @@ -2,15 +2,21 @@ import type { TokenUsage } from '#/kosong/contract/usage'; import type { SubagentModelSource } from '#/session/subagent/configSection'; import { isAbortError } from '#/_base/utils/abort'; +import { ErrorCodes, isError2 } from '#/errors'; +import { REPEAT_BREAKER_STOP_REASON } from '#/agent/toolDedupe/toolDedupe'; import { type AgentTask, type AgentTaskInfoBase, type AgentTaskSink, } from '#/agent/task/types'; +const REPEAT_BREAKER_SETTLE_REASON = + 'stopped by the repeat breaker after issuing the same tool call repeatedly; its output is a handoff, not a finished result'; + type SubagentCompletion = { readonly result: string; readonly usage?: TokenUsage; + readonly stopReason?: string; }; export type SubagentHandle = { @@ -30,6 +36,7 @@ export interface SubagentTaskInfo extends AgentTaskInfoBase { readonly parentToolCallId?: string; readonly model?: string; readonly thinkingEffort?: string; + readonly stopCode?: string; } declare module '#/agent/task/types' { @@ -42,6 +49,19 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function stopCodeOf(error: unknown): string | undefined { + if (!isError2(error)) return undefined; + if (error.code === ErrorCodes.AGENT_NO_FINAL_MESSAGE) { + const stopReason = error.details?.['stopReason']; + if (typeof stopReason === 'string') return stopReason; + } + return error.code; +} + +function completedSettleReason(stopReason: string | undefined): string | undefined { + return stopReason === REPEAT_BREAKER_STOP_REASON ? REPEAT_BREAKER_SETTLE_REASON : undefined; +} + export function createSubagentExecutor( handle: SubagentHandle, abortController: AbortController, @@ -79,6 +99,7 @@ export class SubagentTask implements AgentTask { readonly parentToolCallId?: string; readonly model?: string; readonly thinkingEffort?: string; + private stopCode: string | undefined; constructor( private readonly handle: SubagentHandle, @@ -104,13 +125,18 @@ export class SubagentTask implements AgentTask { try { const outcome = await this.handle.completion; + this.stopCode = outcome.stopReason; sink.appendOutput(outcome.result); - await sink.settle({ status: 'completed' }); + await sink.settle({ + status: 'completed', + stopReason: completedSettleReason(outcome.stopReason), + }); } catch (error: unknown) { if (sink.signal.aborted && (isAbortError(error) || error === sink.signal.reason)) { await sink.settle({ status: 'killed' }); return; } + this.stopCode = stopCodeOf(error); await sink.settle({ status: 'failed', stopReason: errorMessage(error) }); } finally { sink.signal.removeEventListener('abort', requestAbort); @@ -126,6 +152,7 @@ export class SubagentTask implements AgentTask { parentToolCallId: this.parentToolCallId, model: this.model, thinkingEffort: this.thinkingEffort, + stopCode: this.stopCode, }; } } diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts index cbb7e00e082..30c95fca4a5 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts @@ -28,7 +28,6 @@ import { formatByteSize, resolveMaxImageEdgePx, resolveReadImageByteBudget, - type ImageCompressionTelemetry, type ImageCropRegion, } from '#/agent/media/image-compress'; import { @@ -177,7 +176,7 @@ export class ReadMediaFileTool implements AgentTool { readonly name = 'ReadMediaFile' as const; readonly description: string; readonly parameters: Record = toInputJsonSchema(ReadMediaFileInputSchema); - private readonly compressTelemetry: ImageCompressionTelemetry | undefined; + private readonly telemetry: ITelemetryService | undefined; private readonly inlineVideoSupported: boolean; constructor( private readonly runtime: IAgentRuntimeService, @@ -188,8 +187,7 @@ export class ReadMediaFileTool implements AgentTool { inlineVideoSupported?: boolean, ) { this.description = buildDescription(capabilities); - this.compressTelemetry = - telemetry === undefined ? undefined : { client: telemetry, source: 'read_media' }; + this.telemetry = telemetry; this.inlineVideoSupported = inlineVideoSupported ?? false; } @@ -376,7 +374,8 @@ export class ReadMediaFileTool implements AgentTool { if (args.region !== undefined) { const outcome = await cropImageForModel(data, fileType.mimeType, args.region, { skipResize: args.full_resolution === true, - telemetry: this.compressTelemetry, + telemetry: this.telemetry, + telemetrySource: 'read_media', }); if (!outcome.ok) { return { isError: true, output: `Cannot read region from "${args.path}": ${outcome.error}` }; @@ -420,7 +419,8 @@ export class ReadMediaFileTool implements AgentTool { const compressed = await compressImageForModel(data, fileType.mimeType, { byteBudget: readByteBudget, maxEdge, - telemetry: this.compressTelemetry, + telemetry: this.telemetry, + telemetrySource: 'read_media', }); if ( compressed.finalByteLength > readByteBudget || diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 6ff7be2ba94..5dc153ace05 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -9,12 +9,6 @@ export interface AgentProfilePromptPrefixContext { readonly log?: ILogger; } -export interface AgentProfileSummaryPolicy { - readonly minChars: number; - readonly continuationPrompt: string; - readonly retries: number; -} - export interface AgentProfileContext { readonly cwd?: string; readonly cwdListing?: string; @@ -51,7 +45,6 @@ export interface AgentProfile { readonly systemPrompt: (context: AgentProfileContext) => string; readonly renderSystemPrompt: (context: AgentProfileContext) => SystemPromptRenderResult; readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise; - readonly summaryPolicy?: AgentProfileSummaryPolicy; } export type AgentProfileInput = Omit & diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 71a2577e4bf..d427c5d1997 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -1,3 +1,7 @@ +import { readFileSync } from 'node:fs'; + +import { parse as parseToml } from 'smol-toml'; + import { type CollectionView } from '#/_base/di/collection'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; @@ -322,6 +326,7 @@ export class ConfigService extends Disposable implements IConfigService { this._register(this.registry.onDidRegisterOverlay(() => this.reapplyOverlays())); const { configKey } = this; const { homeDir } = this.bootstrap; + this.seedInitialLoad(); this.ready = (async () => { await migrateThinkingEffortMaxToHigh(this.documentStore, configKey, homeDir); await this.load('load'); @@ -519,6 +524,25 @@ export class ConfigService extends Disposable implements IConfigService { return run; } + private seedInitialLoad(): void { + let fileData: ResolvedConfig; + try { + const text = readFileSync(this.bootstrap.configPath, 'utf8'); + const data: unknown = text.trim().length === 0 ? {} : parseToml(text); + if (!isPlainObject(data)) return; + fileData = data; + } catch { + return; + } + this.rawSnake = cloneRecord(fileData); + this.raw = transformTomlData(fileData, this.registry); + this.validated = this.buildValidated(this.raw); + const next = { ...this.validated }; + this.applySectionEnvBindings(next, true); + this.applyEnvOverlay(next); + this.effective = next; + } + private async load(source: ConfigChangeSource): Promise { this.diagnosticsList.length = 0; let fileData: ResolvedConfig = {}; diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionLookup.ts b/packages/agent-core-v2/src/app/sessionManager/sessionLookup.ts index 1d6b4d2f687..b039a1390ae 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionLookup.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionLookup.ts @@ -39,7 +39,7 @@ export async function resumeSessionById( } catch (error) { accessor .get(ITelemetryService) - .withContext({ sessionId }) + .withContext({ session_id: sessionId }) .track2('session_load_failed', { reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', }); diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts deleted file mode 100644 index 7e298e0b37a..00000000000 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createDecorator } from '#/_base/di/instantiation'; - -export type AgentTelemetryContext = { - mode: 'agent' | 'plan'; - provider_type?: string; - protocol?: string; - turn_id?: number; - trace_id?: string; -}; - -export interface IAgentTelemetryContextService { - readonly _serviceBrand: undefined; - - get(): AgentTelemetryContext; - set(patch: Partial): void; -} - -export const IAgentTelemetryContextService = createDecorator( - 'agentTelemetryContextService', -); diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts deleted file mode 100644 index 33ee7acf241..00000000000 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { LifecycleScope } from '#/app/scopes'; - -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { - IAgentTelemetryContextService, - type AgentTelemetryContext, -} from './agentTelemetryContext'; - -export class AgentTelemetryContextService implements IAgentTelemetryContextService { - declare readonly _serviceBrand: undefined; - private context: AgentTelemetryContext; - - constructor() { - this.context = { mode: 'agent' }; - } - - get(): AgentTelemetryContext { - return this.context; - } - - set(patch: Partial): void { - this.context = { ...this.context, ...patch }; - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentTelemetryContextService, - AgentTelemetryContextService, - ScopeActivation.OnScopeCreated, - 'telemetry', -); diff --git a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts index 4b731373970..55112e39e06 100644 --- a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts +++ b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts @@ -6,7 +6,8 @@ import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import type { ITelemetryAppender, TelemetryContextPatch, TelemetryProperties } from './telemetry'; +import type { ITelemetryAppender, TelemetryAppenderRecord } from './telemetry'; +import type { TelemetryProperties } from './context'; import { type CloudContext, type CloudPrimitive, @@ -97,16 +98,17 @@ export class CloudAppender implements ITelemetryAppender { }); } - track(event: string, properties?: TelemetryProperties): void { - const eventSessionId = properties?.['sessionId']; + track(record: TelemetryAppenderRecord): void { + const ambientSessionId = record.context['session_id']; const enriched: EnrichedCloudEvent = { event_id: randomUUID().replaceAll('-', ''), device_id: this.deviceId, - session_id: typeof eventSessionId === 'string' ? eventSessionId : this.sessionId, - event, + session_id: + typeof ambientSessionId === 'string' ? ambientSessionId : this.sessionId, + event: record.event, timestamp: Date.now() / 1000, - properties: cleanTelemetryProperties(sanitizeProperties(properties)), - context: { ...this.context }, + properties: cleanTelemetryProperties(sanitizeProperties(record.properties)), + context: this.envelopeContext(record.context), }; this.buffer.push(enriched); if (this.buffer.length >= this.flushThreshold) { @@ -114,19 +116,13 @@ export class CloudAppender implements ITelemetryAppender { } } - setContext(patch: TelemetryContextPatch): void { - const deviceId = patch['deviceId']; - if (typeof deviceId === 'string') { - this.deviceId = deviceId; - } - const sessionId = patch['sessionId']; - if (typeof sessionId === 'string') { - this.sessionId = sessionId; - } - const model = patch['model']; - if (typeof model === 'string') { - setPrimitive(this.context, 'model', model); + private envelopeContext(ambient: TelemetryProperties): CloudContext { + const context: CloudContext = { ...this.context }; + const ambientModel = ambient['model']; + if (typeof ambientModel === 'string' && ambientModel.length > 0) { + context['model'] = ambientModel; } + return context; } async flush(): Promise { diff --git a/packages/agent-core-v2/src/app/telemetry/consoleAppender.ts b/packages/agent-core-v2/src/app/telemetry/consoleAppender.ts index 6ac9a81175f..e4b535b11f8 100644 --- a/packages/agent-core-v2/src/app/telemetry/consoleAppender.ts +++ b/packages/agent-core-v2/src/app/telemetry/consoleAppender.ts @@ -1,4 +1,5 @@ -import type { ITelemetryAppender, TelemetryProperties } from './telemetry'; +import type { ITelemetryAppender, TelemetryAppenderRecord } from './telemetry'; +import type { TelemetryProperties } from './context'; export interface ConsoleAppenderOptions { readonly prefix?: string; @@ -19,10 +20,12 @@ export class ConsoleAppender implements ITelemetryAppender { this.log = options.log ?? defaultLog; } - track(event: string, properties?: TelemetryProperties): void { + track(record: TelemetryAppenderRecord): void { const payload = - properties === undefined ? '' : ` ${stringifyProperties(properties, this.pretty)}`; - this.log(`${this.prefix} ${event}${payload}`); + Object.keys(record.properties).length === 0 + ? '' + : ` ${stringifyProperties(record.properties, this.pretty)}`; + this.log(`${this.prefix} ${record.event}${payload}`); } } diff --git a/packages/agent-core-v2/src/app/telemetry/context.ts b/packages/agent-core-v2/src/app/telemetry/context.ts new file mode 100644 index 00000000000..1674961dec4 --- /dev/null +++ b/packages/agent-core-v2/src/app/telemetry/context.ts @@ -0,0 +1,27 @@ +export type TelemetryPrimitive = string | number | boolean | null | undefined; + +export type TelemetryProperties = Readonly>; + +export interface SessionTelemetryContext { + readonly session_id: string; +} + +export interface AgentTelemetryContext { + readonly agent_id: string; + readonly mode: 'agent' | 'plan'; + readonly provider_type?: string; + readonly protocol?: string; +} + +export interface TurnTelemetryContext { + readonly turn_id?: number; + readonly trace_id?: string; + readonly thinking_effort?: string; +} + +export interface TelemetryContextPatch + extends Partial, + Partial, + Partial { + readonly model?: string; +} diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 8d4e1ec66de..318e231bc72 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -363,6 +363,11 @@ export interface ToolCallTurnRepeatEvent { trace_id?: string; } +export interface ToolCallRepeatHandoffEvent { + turn_id?: number; + outcome: 'text' | 'vetoed'; +} + export interface AgentsMdReminderShownEvent { turn_id: number; tool_name: string; @@ -1010,6 +1015,14 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced the repeated tool call; absent for non-Kimi protocols', }, }), + tool_call_repeat_handoff: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'The text-only handoff step that follows a repeat-breaker force stop finished.', + properties: { + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', + outcome: 'Whether the model answered in text or its tool calls were vetoed', + }, + }), agents_md_reminder_shown: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'An AGENTS.md discovery reminder is queued for context injection after a tool call.', diff --git a/packages/agent-core-v2/src/app/telemetry/telemetry.ts b/packages/agent-core-v2/src/app/telemetry/telemetry.ts index ba6e6154902..06963bd1fa3 100644 --- a/packages/agent-core-v2/src/app/telemetry/telemetry.ts +++ b/packages/agent-core-v2/src/app/telemetry/telemetry.ts @@ -1,48 +1,43 @@ import { createDecorator } from '#/_base/di/instantiation'; import type { IDisposable } from '#/_base/di/lifecycle'; +import type { + TelemetryContextPatch, + TelemetryPrimitive, + TelemetryProperties, +} from './context'; import type { StrictPropertyCheck, TelemetryEventName, TelemetryEventPayload, } from './events'; -export type TelemetryPrimitive = string | number | boolean | null | undefined; - -export type TelemetryProperties = Readonly>; +export type { TelemetryContextPatch, TelemetryPrimitive, TelemetryProperties } from './context'; -export type TelemetryContextPatch = TelemetryProperties; +export interface TelemetryAppenderRecord { + readonly event: string; + readonly context: TelemetryProperties; + readonly properties: TelemetryProperties; +} export interface ITelemetryAppender { - track(event: string, properties?: TelemetryProperties): void; - withContext?(patch: TelemetryContextPatch): ITelemetryAppender; - setContext?(patch: TelemetryContextPatch): void; + track(record: TelemetryAppenderRecord): void; flush?(): Promise | void; shutdown?(): Promise | void; } -export interface TelemetryServiceOptions { - readonly appender?: ITelemetryAppender; - readonly appenders?: readonly ITelemetryAppender[]; - readonly context?: TelemetryProperties; - readonly sessionId?: string; - readonly agentId?: string; - readonly turnId?: string; -} - export interface ITelemetryService { readonly _serviceBrand: undefined; - track(event: string, properties?: TelemetryProperties): void; track2 = never>( event: K, properties?: StrictPropertyCheck, E>, ): void; withContext(patch: TelemetryContextPatch): ITelemetryService; setContext(patch: TelemetryContextPatch): void; + getContext(): Readonly; addAppender(appender: ITelemetryAppender): IDisposable; removeAppender(appender: ITelemetryAppender): void; - setAppender(appender: ITelemetryAppender): void; setEnabled(enabled: boolean): void; flush(): Promise; shutdown(): Promise; @@ -50,21 +45,20 @@ export interface ITelemetryService { export const nullTelemetryAppender: ITelemetryAppender = { track: () => {}, - withContext: () => nullTelemetryAppender, - setContext: () => {}, flush: () => {}, shutdown: () => {}, }; +const EMPTY_CONTEXT: Readonly = Object.freeze({}); + export const noopTelemetryService: ITelemetryService = { _serviceBrand: undefined, - track: () => {}, track2: () => {}, withContext: () => noopTelemetryService, setContext: () => {}, + getContext: () => EMPTY_CONTEXT, addAppender: () => ({ dispose: () => {} }), removeAppender: () => {}, - setAppender: () => {}, setEnabled: () => {}, flush: async () => {}, shutdown: async () => {}, diff --git a/packages/agent-core-v2/src/app/telemetry/telemetryService.ts b/packages/agent-core-v2/src/app/telemetry/telemetryService.ts index 609d896b82d..94fd9e7f36e 100644 --- a/packages/agent-core-v2/src/app/telemetry/telemetryService.ts +++ b/packages/agent-core-v2/src/app/telemetry/telemetryService.ts @@ -4,55 +4,115 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import type { - StrictPropertyCheck, - TelemetryEventName, - TelemetryEventPayload, + TelemetryContextPatch, + TelemetryPrimitive, + TelemetryProperties, +} from './context'; +import { + type StrictPropertyCheck, + type TelemetryEventName, + type TelemetryEventPayload, } from './events'; import { - ITelemetryService, type ITelemetryAppender, + ITelemetryService, nullTelemetryAppender, - type TelemetryContextPatch, - type TelemetryProperties, + type TelemetryAppenderRecord, } from './telemetry'; -export class TelemetryService implements ITelemetryService { - declare readonly _serviceBrand: undefined; +type MutableContext = Record; - private appenders: ITelemetryAppender[] = [nullTelemetryAppender]; - private context: TelemetryProperties = {}; - private enabled = true; +const WIRE_SESSION_ID_PROPERTY = 'sessionId'; - track(event: string, properties?: TelemetryProperties): void { - if (!this.enabled) { - return; +function applyPatch(target: MutableContext, patch: TelemetryContextPatch): MutableContext { + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + delete target[key]; + } else { + target[key] = value; } - const merged = { ...this.context, ...properties }; - for (const appender of this.appenders) { - try { - appender.track(event, merged); - } catch (err) { - onUnexpectedError(err); + } + return target; +} + +export function composeTelemetryProperties( + ambient: TelemetryProperties, + explicit: TelemetryProperties | undefined, +): TelemetryProperties { + const properties: MutableContext = {}; + for (const [key, value] of Object.entries(ambient)) { + if (key === 'session_id' || value === undefined) { + continue; + } + properties[key] = value; + } + if (ambient['session_id'] !== undefined) { + properties[WIRE_SESSION_ID_PROPERTY] = ambient['session_id']; + } + if (explicit !== undefined) { + for (const [key, value] of Object.entries(explicit)) { + if (value !== undefined) { + properties[key] = value; } } } + return properties; +} + +export interface TelemetryScopeBinding extends IDisposable { + readonly telemetry: ITelemetryService; +} + +interface TelemetryAmbientSource { + ambient(): TelemetryProperties; +} + +export interface ITelemetryScopeBindingHost { + createScopeBinding(seed: TelemetryContextPatch): TelemetryScopeBinding; +} + +export function bindTelemetryScope( + parent: ITelemetryService, + seed: TelemetryContextPatch, +): TelemetryScopeBinding { + const host = parent as ITelemetryService & Partial; + if (host.createScopeBinding !== undefined) { + return host.createScopeBinding(seed); + } + return { telemetry: parent.withContext(seed), dispose: () => {} }; +} + +export class TelemetryService + implements ITelemetryService, ITelemetryScopeBindingHost, TelemetryAmbientSource +{ + declare readonly _serviceBrand: undefined; + + private appenders: ITelemetryAppender[] = [nullTelemetryAppender]; + private context: MutableContext = {}; + private enabled = true; track2 = never>( event: K, properties?: StrictPropertyCheck, E>, ): void { - this.track(event, properties as TelemetryProperties); + this.dispatch(event, this.ambient(), properties as TelemetryProperties | undefined); } withContext(patch: TelemetryContextPatch): ITelemetryService { - return new TelemetryContextView(this, patch); + return new TelemetrySnapshotView(this, applyPatch(this.ambient(), patch)); } setContext(patch: TelemetryContextPatch): void { - this.context = { ...this.context, ...patch }; - for (const appender of this.appenders) { - appender.setContext?.(patch); - } + applyPatch(this.context, patch); + } + + getContext(): Readonly { + return this.ambient(); + } + + createScopeBinding(seed: TelemetryContextPatch): TelemetryScopeBinding { + const bound = new BoundTelemetryService(this, this, applyPatch({}, seed)); + return { telemetry: bound, dispose: () => bound.dispose() }; } addAppender(appender: ITelemetryAppender): IDisposable { @@ -64,10 +124,6 @@ export class TelemetryService implements ITelemetryService { this.appenders = this.appenders.filter((a) => a !== appender); } - setAppender(appender: ITelemetryAppender): void { - this.appenders = [appender]; - } - setEnabled(enabled: boolean): void { this.enabled = enabled; } @@ -87,36 +143,82 @@ export class TelemetryService implements ITelemetryService { ), ); } + + ambient(): TelemetryProperties { + return { ...this.context }; + } + + dispatch( + event: string, + ambient: TelemetryProperties, + properties: TelemetryProperties | undefined, + ): void { + if (!this.enabled) { + return; + } + const record: TelemetryAppenderRecord = { + event, + context: { ...ambient }, + properties: composeTelemetryProperties(ambient, properties), + }; + for (const appender of this.appenders) { + try { + appender.track(record); + } catch (err) { + onUnexpectedError(err); + } + } + } } -class TelemetryContextView implements ITelemetryService { +class BoundTelemetryService + implements ITelemetryService, ITelemetryScopeBindingHost, TelemetryAmbientSource +{ declare readonly _serviceBrand: undefined; - private context: TelemetryProperties; + + private disposed = false; constructor( - private readonly root: ITelemetryService, - context: TelemetryProperties, - ) { - this.context = context; - } + private readonly root: TelemetryService, + private readonly parent: TelemetryAmbientSource, + private readonly fragment: MutableContext, + ) {} - track(event: string, properties?: TelemetryProperties): void { - this.root.track(event, { ...this.context, ...properties }); + ambient(): TelemetryProperties { + const inherited = this.parent.ambient(); + if (this.disposed) { + return inherited; + } + return { ...inherited, ...this.fragment }; } track2 = never>( event: K, properties?: StrictPropertyCheck, E>, ): void { - this.track(event, properties as TelemetryProperties); + this.root.dispatch(event, this.ambient(), properties as TelemetryProperties | undefined); } withContext(patch: TelemetryContextPatch): ITelemetryService { - return new TelemetryContextView(this.root, { ...this.context, ...patch }); + return new TelemetrySnapshotView( + this.root, + applyPatch(this.ambient(), patch), + ); } setContext(patch: TelemetryContextPatch): void { - this.context = { ...this.context, ...patch }; + if (!this.disposed) { + applyPatch(this.fragment, patch); + } + } + + getContext(): Readonly { + return this.ambient(); + } + + createScopeBinding(seed: TelemetryContextPatch): TelemetryScopeBinding { + const bound = new BoundTelemetryService(this.root, this, applyPatch({}, seed)); + return { telemetry: bound, dispose: () => bound.dispose() }; } addAppender(appender: ITelemetryAppender): IDisposable { @@ -127,8 +229,59 @@ class TelemetryContextView implements ITelemetryService { this.root.removeAppender(appender); } - setAppender(appender: ITelemetryAppender): void { - this.root.setAppender(appender); + setEnabled(enabled: boolean): void { + this.root.setEnabled(enabled); + } + + flush(): Promise { + return this.root.flush(); + } + + shutdown(): Promise { + return this.root.shutdown(); + } + + dispose(): void { + this.disposed = true; + } +} + +class TelemetrySnapshotView implements ITelemetryService { + declare readonly _serviceBrand: undefined; + private context: MutableContext; + + constructor( + private readonly root: TelemetryService, + context: TelemetryProperties, + ) { + this.context = { ...context }; + } + + track2 = never>( + event: K, + properties?: StrictPropertyCheck, E>, + ): void { + this.root.dispatch(event, this.context, properties as TelemetryProperties | undefined); + } + + withContext(patch: TelemetryContextPatch): ITelemetryService { + return new TelemetrySnapshotView(this.root, applyPatch({ ...this.context }, patch)); + } + + setContext(patch: TelemetryContextPatch): void { + applyPatch(this.context, patch); + } + + getContext(): Readonly { + return { ...this.context }; + } + + addAppender(appender: ITelemetryAppender): IDisposable { + return this.root.addAppender(appender); + } + + removeAppender(appender: ITelemetryAppender): void { + this.root.removeAppender(appender); } setEnabled(enabled: boolean): void { diff --git a/packages/agent-core-v2/src/features/plan/planService.ts b/packages/agent-core-v2/src/features/plan/planService.ts index 0cbae892a46..3df74d331ab 100644 --- a/packages/agent-core-v2/src/features/plan/planService.ts +++ b/packages/agent-core-v2/src/features/plan/planService.ts @@ -19,7 +19,6 @@ import type { BeforeToolExecuteEvent, ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -53,7 +52,6 @@ export class AgentPlanService extends Service implements IAgentPlanService { @IHostFileSystem private readonly hostFs: IHostFileSystem, @IBlobStore private readonly blobs: IBlobStore, @IAgentReminderService reminder: IAgentReminderService, - @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IEventBus eventBus: IEventBus, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @ISessionContext private readonly sessionCtx: ISessionContext, @@ -61,7 +59,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, - @ITelemetryService telemetry: ITelemetryService, + @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentStateService private readonly agentState: IAgentStateService, ) { super(); @@ -152,7 +150,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { } private restoreTelemetryMode(): void { - this.telemetryContext.set({ mode: this.isActive ? 'plan' : 'agent' }); + this.telemetry.setContext({ mode: this.isActive ? 'plan' : 'agent' }); } private createPlanId(): string { @@ -169,7 +167,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { try { await this.ensurePlanDirectory(planFilePath); await this.dispatcher.dispatch(new PlanModeEnter({ agentId: this.agentCtx.agentId, id })); - this.telemetryContext.set({ mode: 'plan' }); + this.telemetry.setContext({ mode: 'plan' }); enterRecorded = true; if (createFile) { await this.writeEmptyPlanFile(planFilePath); @@ -184,7 +182,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { cancel(id?: string): void { void this.dispatcher.dispatch(new PlanModeCancel({ agentId: this.agentCtx.agentId, id })); - this.telemetryContext.set({ mode: 'agent' }); + this.telemetry.setContext({ mode: 'agent' }); } async clear(): Promise { @@ -195,7 +193,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { exit(id?: string): void { void this.dispatcher.dispatch(new PlanModeExit({ agentId: this.agentCtx.agentId, id })); - this.telemetryContext.set({ mode: 'agent' }); + this.telemetry.setContext({ mode: 'agent' }); } async recordRevision(): Promise { diff --git a/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts b/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts index dbc28999771..75443eb24f5 100644 --- a/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts +++ b/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts @@ -32,6 +32,7 @@ export type AgentRunAttemptHandle = { readonly completion: Promise<{ readonly result: string; readonly usage?: TokenUsage; + readonly stopReason?: string; }>; }; @@ -311,6 +312,7 @@ export class AgentRunBatch { status: 'completed', result: completion.result, usage: completion.usage, + stopReason: completion.stopReason, }; } catch (error) { if (isProviderRateLimitError(error)) { diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts index 986ada8dabf..6ad754ad9a8 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts @@ -42,6 +42,7 @@ export interface SessionSwarmRunResult { readonly state?: 'started' | 'not_started'; readonly result?: string; readonly usage?: TokenUsage; + readonly stopReason?: string; readonly error?: string; } diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index 74ae9af37dc..9bc8b30d317 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -193,7 +193,11 @@ export class SessionSwarmService implements ISessionSwarmService { return { agentId, profileName, - completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })), + completion: mirrored.then((r) => ({ + result: r.summary, + usage: r.usage, + stopReason: r.stopReason, + })), }; } diff --git a/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts index 997fd27549e..512a5634c63 100644 --- a/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/features/swarm/tools/agent-swarm/agentSwarmTool.ts @@ -65,6 +65,7 @@ interface SwarmRunResult { readonly status: 'completed' | 'failed' | 'aborted'; readonly state?: 'started' | 'not_started'; readonly result?: string; + readonly stopReason?: string; readonly error?: string; } @@ -301,7 +302,7 @@ function renderSwarmResults(results: readonly SwarmRunResult[]): string { const failed = results.filter((result) => result.status === 'failed').length; const aborted = results.filter((result) => result.status === 'aborted').length; const shouldRenderResumeHint = - results.some((result) => result.status !== 'completed') && + results.some((result) => result.status !== 'completed' || result.stopReason !== undefined) && results.some((result) => result.agentId !== undefined); const lines = [ '', @@ -319,9 +320,11 @@ function renderSwarmResults(results: readonly SwarmRunResult[]): string { const mode = result.spec.kind === 'resume' ? ' mode="resume"' : ''; const item = result.spec.item === undefined ? '' : ` item="${escapeXmlAttribute(result.spec.item)}"`; const state = result.state === undefined ? '' : ` state="${result.state}"`; + const stopReason = + result.stopReason === undefined ? '' : ` stop_reason="${escapeXmlAttribute(result.stopReason)}"`; const body = result.status === 'completed' ? (result.result ?? '') : (result.error ?? 'unknown error'); lines.push( - `${body}`, + `${body}`, ); } diff --git a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md index 0c02d356f74..896bbb650e5 100644 --- a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md +++ b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md @@ -16,10 +16,7 @@ Working principles: ## Prepare (only when the directory is not a tower-ready git repo) -`TowerInit` requires a git repository with at least one commit. If `git rev-parse --is-inside-work-tree` fails: - -- **Empty directory** → `git init` + `git commit --allow-empty -m "tower: init"`, then proceed. No confirmation needed. -- **Non-empty directory** → never `git add -A`: a blind initial commit can seal secrets, large binaries, or dependency directories into history irreversibly. Survey the directory (file count, largest files, secret-looking names like `.env` or `*.pem`), present the summary, and ask the human **exactly once** whether to initialize and commit the existing files — but only when asking is possible. Under auto permission mode `AskUserQuestion` is disabled: do not call it into a deny error. Default to the safe behavior instead — do NOT commit existing files; stop tower there and tell the human in your reply the two commands to run themselves (`git init` plus an initial commit of their choosing). If they agree to the commit, write a conservative `.gitignore` (dependencies, build output, secrets), show the staged list, commit, proceed. +`TowerInit` requires a git repository with at least one commit. If the session working directory is not inside one, the engine bootstraps it for you: `git init`, then an initial commit on the base branch — an empty directory gets `git commit --allow-empty -m "tower: init"`; a non-empty directory gets every present file committed as a dirty-base snapshot (`tower: snapshot of uncommitted base checkout changes (base )` — the same semantics as starting a tower over an uncommitted checkout). If the directory holds secrets or large files that must not enter history, move them out or add a `.gitignore` BEFORE starting the tower — the snapshot commits everything present. ## Tower workflow diff --git a/packages/agent-core-v2/src/features/tower/protocol/git.ts b/packages/agent-core-v2/src/features/tower/protocol/git.ts index 665f9e0a5c4..0fe3ddda838 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/git.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -76,10 +76,35 @@ export async function branchExists(cwd: string, branch: string): Promise { + await git(cwd, ['init']); +} + +async function gitCommit(cwd: string, args: readonly string[]): Promise { + try { + await git(cwd, args); + } catch (error) { + if (!(error instanceof GitError) || !/identity unknown/.test(error.stderr)) { + throw error; + } + await git(cwd, [ + '-c', + 'user.name=Kimi Tower', + '-c', + 'user.email=kimi-tower@localhost', + ...args, + ]); + } +} + export async function checkoutNewLocalBranch(cwd: string, branch: string): Promise { await git(cwd, ['checkout', '-b', branch]); } +export async function commitAllowEmpty(cwd: string, message: string): Promise { + await gitCommit(cwd, ['commit', '--allow-empty', '-m', message]); +} + export async function commitPaths( cwd: string, paths: readonly string[], @@ -88,7 +113,7 @@ export async function commitPaths( for (let i = 0; i < paths.length; i += ADD_PATHS_CHUNK) { await git(cwd, ['add', '-A', '--', ...paths.slice(i, i + ADD_PATHS_CHUNK)]); } - await git(cwd, ['commit', '-m', message]); + await gitCommit(cwd, ['commit', '-m', message]); } export async function isAncestor(cwd: string, ancestor: string, ref: string): Promise { diff --git a/packages/agent-core-v2/src/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts index e196933364a..8825d49eaa5 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -9,9 +9,13 @@ import { parseFrontmatter, renderFrontmatter } from './frontmatter'; import { branchExists, branchTip, + checkoutNewLocalBranch, + commitAllowEmpty, + commitPaths, currentBranch, diffNameOnly, hasAnyCommit, + initRepository, isAncestor, isInsideRepo, isWorktreeDirty, @@ -155,12 +159,28 @@ export class TowerStore { } } - async init(sessionId?: string, base?: string): Promise { - if (!(await isInsideRepo(this.repoRoot))) { - throw new TowerProtocolError( - 'tower needs a git repository (the session working directory is not inside one)', - ); + async ensureRepository(base?: string): Promise { + if (await isInsideRepo(this.repoRoot)) return; + await initRepository(this.repoRoot); + const unborn = (await tryGit(this.repoRoot, ['symbolic-ref', '--short', 'HEAD'])) ?? 'main'; + const resolvedBase = base ?? unborn; + if (resolvedBase !== unborn) { + await checkoutNewLocalBranch(this.repoRoot, resolvedBase); } + const dirty = await listBaseDirtyEntries(this.repoRoot); + if (dirty.length === 0) { + await commitAllowEmpty(this.repoRoot, 'tower: init'); + return; + } + await commitPaths( + this.repoRoot, + dirty.map((entry) => entry.path), + `tower: snapshot of uncommitted base checkout changes (base ${resolvedBase})`, + ); + } + + async init(sessionId?: string, base?: string): Promise { + await this.ensureRepository(base); if (!(await hasAnyCommit(this.repoRoot))) { throw new TowerProtocolError( 'the repository has no commits yet — create an initial commit first', @@ -255,6 +275,15 @@ export class TowerStore { return stale.map((agent) => agent.name); } + async release(sessionId: string): Promise { + if (!(await this.isInitialized())) return; + const state = await this.load(); + if (state.sessionId !== sessionId) return; + state.sessionId = undefined; + await this.save(state); + await this.appendLog(TOWER_NAME, 'release', { session: sessionId }); + } + private async ensureGitExclude(): Promise { const gitDir = (await readGitDir(this.repoRoot)) ?? join(this.repoRoot, '.git'); const excludePath = join(gitDir, 'info', 'exclude'); diff --git a/packages/agent-core-v2/src/features/tower/tower.ts b/packages/agent-core-v2/src/features/tower/tower.ts index a9fd8acf795..8c3dee0ba63 100644 --- a/packages/agent-core-v2/src/features/tower/tower.ts +++ b/packages/agent-core-v2/src/features/tower/tower.ts @@ -15,14 +15,50 @@ export const TOWER_TOOL_NAMES = [ export const TOWER_WORKER_PROFILE = 'tower-worker'; +export function hasPinnedPermissionMode(profileName: string | undefined): boolean { + return profileName === TOWER_WORKER_PROFILE; +} + export const TOWER_FLAG_ID = 'tower'; +export type TowerEnterFailure = + | { + readonly entered: false; + readonly reason: 'not-main-agent' | 'experiment-off' | 'feature-not-assembled'; + } + | { + readonly entered: false; + readonly reason: 'owned-by-live-session'; + readonly owner: string; + readonly ownerTitle?: string; + }; + +export type TowerEnterResult = { readonly entered: true } | TowerEnterFailure; + +export function towerEnterFailureMessage(failure: TowerEnterFailure): string { + switch (failure.reason) { + case 'not-main-agent': + return 'tower mode is only supported by the main agent'; + case 'experiment-off': + return 'the tower experiment is disabled; enable it with KIMI_CODE_EXPERIMENTAL_TOWER=1 or `[experimental] tower = true` in config.toml'; + case 'feature-not-assembled': + return 'the tower feature is not assembled in this process; a restart is required'; + case 'owned-by-live-session': { + const owner = + failure.ownerTitle === undefined + ? failure.owner + : `${failure.ownerTitle} (${failure.owner})`; + return `another live session owns the workspace tower (session ${owner})`; + } + } +} + export interface IAgentTowerService { readonly _serviceBrand: undefined; readonly isActive: boolean; readonly requestedBase: string | undefined; - enter(base?: string): Promise; + enter(base?: string): Promise; exit(): void; } diff --git a/packages/agent-core-v2/src/features/tower/towerService.ts b/packages/agent-core-v2/src/features/tower/towerService.ts index 796ec7609e8..c2b7834e435 100644 --- a/packages/agent-core-v2/src/features/tower/towerService.ts +++ b/packages/agent-core-v2/src/features/tower/towerService.ts @@ -1,7 +1,8 @@ import { join } from 'node:path'; import { Disposable } from '#/_base/di/lifecycle'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; import { IAgentReminderService } from '#/features/reminder/reminderService'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -25,6 +26,8 @@ import { ISessionActivityView } from '#/session/sessionActivity/sessionActivity' import { isWithinDirectory } from '#/tool/path-access'; import type { ToolFileAccess } from '#/tool/toolContract'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { isUntitled } from '#/session/sessionMetadata/promptMetadata'; import { SubagentStarted } from '#/session/subagent/mirrorAgentRun'; import { TowerModeInjection } from './injection/towerModeInjection'; import { @@ -43,6 +46,7 @@ import { TOWER_FLAG_ID, TOWER_TOOL_NAMES, TOWER_WORKER_PROFILE, + type TowerEnterResult, } from './tower'; import { isTowerFeatureAssembled } from './towerFeature'; import { TowerModeEnter, TowerModeExit, towerBaseKey, towerKey, towerOwnerKey } from './towerOps'; @@ -67,6 +71,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService @IAgentReminderService reminder: IAgentReminderService, @IAgentContextMemoryService context: IAgentContextMemoryService, @IEventBus eventBus: IEventBus, + @ILogService private readonly log: ILogService, ) { super(); this.agentState.contributeState(towerKey); @@ -186,10 +191,10 @@ export class AgentTowerService extends Disposable implements IAgentTowerService ); } - async enter(base?: string): Promise { - if (this.agentCtx.agentId !== 'main') return; - if (!this.flags.enabled(TOWER_FLAG_ID)) return; - if (!isTowerFeatureAssembled(this.flags)) return; + async enter(base?: string): Promise { + if (this.agentCtx.agentId !== 'main') return { entered: false, reason: 'not-main-agent' }; + if (!this.flags.enabled(TOWER_FLAG_ID)) return { entered: false, reason: 'experiment-off' }; + if (!isTowerFeatureAssembled(this.flags)) return { entered: false, reason: 'feature-not-assembled' }; if (base !== undefined) { await this.prepareUserBase(base); } @@ -197,14 +202,17 @@ export class AgentTowerService extends Disposable implements IAgentTowerService if (base !== undefined && base !== this.agentState.get(towerBaseKey)) { this.dispatchEnter(base); } - return; + return { entered: true }; } const owner = await this.resolveTowerOwner(); if (owner !== undefined && owner !== this.sessionCtx.sessionId) { const ownerHandle = this.sessions.get(owner); if (ownerHandle !== undefined) { const activity = ownerHandle.accessor.get(ISessionActivityView).state(); - if (activity.busy || activity.pendingInteraction !== 'none') return; + if (activity.busy || activity.pendingInteraction !== 'none') { + const ownerTitle = await this.resolveOwnerTitle(ownerHandle); + return { entered: false, reason: 'owned-by-live-session', owner, ownerTitle }; + } ownerHandle.accessor .get(IAgentLifecycleService) .handleOf('main') @@ -215,6 +223,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService for (const name of TOWER_MODE_TOOLS) this.profile.addActiveTool(name); this.lastPublished = true; this.dispatchEnter(base); + return { entered: true }; } get requestedBase(): string | undefined { @@ -224,6 +233,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService private async prepareUserBase(base: string): Promise { const repoRoot = resolveTowerRepoRoot(this.sessionCtx.cwd); const store = new TowerStore(repoRoot); + await store.ensureRepository(base); if (await store.isInitialized()) { const state = await store.load(); if (state.base === base) { @@ -289,6 +299,19 @@ export class AgentTowerService extends Disposable implements IAgentTowerService if (!this.agentState.get(towerKey)) return; this.lastPublished = false; void this.dispatcher.dispatch(new TowerModeExit({ agentId: this.agentCtx.agentId })); + void this.releaseTowerOwnership(); + } + + private async releaseTowerOwnership(): Promise { + const store = new TowerStore(resolveTowerRepoRoot(this.sessionCtx.cwd)); + await store.release(this.sessionCtx.sessionId).then( + () => undefined, + (error: unknown) => { + this.log.warn( + `failed to release tower workspace ownership: ${error instanceof Error ? error.message : String(error)}`, + ); + }, + ); } get isActive(): boolean { @@ -306,7 +329,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService const owner = await this.resolveTowerOwner(); if (owner === undefined || owner === this.sessionCtx.sessionId) return; if (this.sessions.get(owner) === undefined) return; - void this.dispatcher.dispatch(new TowerModeExit({ agentId: this.agentCtx.agentId })); + this.exit(); } private async resolveTowerOwner(): Promise { @@ -318,6 +341,15 @@ export class AgentTowerService extends Disposable implements IAgentTowerService return storeOwner ?? this.agentState.get(towerOwnerKey); } + private async resolveOwnerTitle(ownerHandle: ISessionScopeHandle): Promise { + try { + const meta = await ownerHandle.accessor.get(ISessionMetadata).read(); + return isUntitled(meta.title) ? undefined : meta.title; + } catch { + return undefined; + } + } + private async recordTowerAgentDeath(info: AgentTaskInfo): Promise { if (info.kind !== 'agent') return; if (info.agentId === undefined) return; diff --git a/packages/agent-core-v2/src/features/tower/workerProfile.ts b/packages/agent-core-v2/src/features/tower/workerProfile.ts index d5e35bb69c1..9fe6c0a3e10 100644 --- a/packages/agent-core-v2/src/features/tower/workerProfile.ts +++ b/packages/agent-core-v2/src/features/tower/workerProfile.ts @@ -7,7 +7,6 @@ import { skillActiveFor, TASK_AGENT_ROLE_PREFIX, } from '#/app/agentProfileCatalog/profile-shared'; -import SUMMARY_CONTINUATION_PROMPT from '../../session/agentLifecycle/profile/summary-continuation.md?raw'; import { TOWER_WORKER_PROFILE } from './tower'; import TOWER_WORKER_ROLE_OVERLAY from './tower-worker-overlay.md?raw'; @@ -48,17 +47,11 @@ const CODER_ROLE = 'Your final message is the entire handoff — the parent sees nothing else from your run. ' + 'Make it technically complete: what you changed and why, the path of every file you touched, ' + 'how you verified the change (tests or commands run, with results), and anything left undone ' + - 'or worth follow-up. A final message of only a sentence or two is treated as too brief and ' + - 'sent back to you for expansion, costing an extra turn.'; + 'or worth follow-up. If you are stopped before finishing, the parent receives only what ' + + 'you have written so far, so keep the handoff current.'; const TOWER_WORKER_ROLE = `${CODER_ROLE}\n\n${TOWER_WORKER_ROLE_OVERLAY.trim()}`; -const DEFAULT_SUMMARY_POLICY = { - minChars: 200, - continuationPrompt: SUMMARY_CONTINUATION_PROMPT, - retries: 1, -} as const; - export const TOWER_WORKER_PROFILE_DEF: AgentProfile = normalizeAgentProfile({ name: TOWER_WORKER_PROFILE, description: @@ -71,5 +64,4 @@ export const TOWER_WORKER_PROFILE_DEF: AgentProfile = normalizeAgentProfile({ renderSystemPromptResult(TOWER_WORKER_ROLE, context, { skillActive: skillActiveFor(TOWER_WORKER_TOOLS), }), - summaryPolicy: DEFAULT_SUMMARY_POLICY, }); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index d9594b80c4c..ce855cbd1b9 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -61,10 +61,9 @@ export * from '#/wire/record'; export * from '#/wire/migration/migration'; export * from '#/session/sessionLog/sessionLogService'; export * from '#/app/telemetry/telemetry'; +export * from '#/app/telemetry/context'; export * from '#/app/telemetry/events'; export * from '#/app/telemetry/telemetryService'; -export * from '#/app/telemetry/agentTelemetryContext'; -export * from '#/app/telemetry/agentTelemetryContextService'; export * from '#/app/telemetry/consoleAppender'; export * from '#/app/telemetry/cloudAppender'; export * from '#/app/bootstrap/bootstrap'; @@ -670,7 +669,6 @@ export { READ_IMAGE_BYTE_BUDGET, resolveMaxImageEdgePx, resolveReadImageByteBudget, - type ImageCompressionTelemetry, } from '#/agent/media/image-compress'; export { MODEL_ACCEPTED_IMAGE_MIMES, @@ -746,6 +744,7 @@ export * from '#/agent/loop/loop'; export * from '#/agent/loop/loopService'; export * from '#/agent/loop/loopContinuation'; export * from '#/agent/loop/loopContinuationService'; +export * from '#/agent/loop/handoffStep'; export * from '#/agent/interruptionReminder/interruptionReminder'; export * from '#/agent/interruptionReminder/interruptionReminderService'; export * from '#/agent/interruptionReminder/interruptionReminderOps'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 2974a52420b..6afd6746215 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -19,10 +19,11 @@ import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSe import { permissionModeConfiguredKey } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { profileKey } from '#/agent/profile/profileOps'; -import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; +import { hasPinnedPermissionMode } from '#/features/tower/tower'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { withSubagentProfile } from '#/session/agentLifecycle/subagentMetadata'; import { agentContextOf, IAgentScopeContext, @@ -43,6 +44,7 @@ import { IWireService } from '#/wire/wire'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { bindTelemetryScope } from '#/app/telemetry/telemetryService'; import type { AgentContext } from '#/agent/agentContext/agentContext'; import { ManagedAgent } from './managedAgent'; @@ -142,6 +144,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle let stage = 'scope'; let containerRef: InstantiationService | undefined; let createdHandle: IAgentScopeHandle | undefined; + const telemetryBinding = bindTelemetryScope(this.telemetry, { + agent_id: agentId, + mode: 'agent', + }); try { const handle = createScopedChildHandle( this.instantiation, @@ -150,13 +156,17 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle { seeds: [ [IAgentScopeContext, scopeContext], - [ITelemetryService, this.telemetry.withContext({ agent_id: agentId })], + [ITelemetryService, telemetryBinding.telemetry], [IAgentRuntimeBindingSeed, { _serviceBrand: undefined, binding: { workspaceId: this.ctx.workspaceId, runtimeId: opts.runtimeId ?? 'local' }, }], ], configureContainer: (container) => { + container.anchorKernelEntry( + () => telemetryBinding.dispose(), + 'telemetry:agent-context', + ); container.anchorKernelFinalizer(() => { eventBus?.deactivateAgent(agent); }, 'agent-event-bus-deactivate'); @@ -184,7 +194,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle type: agentId === 'main' ? 'main' : 'sub', parentAgentId: agentId === 'main' ? undefined : 'main', forkedFrom: opts.forkedFrom, - labels: opts.labels, + labels: withSubagentProfile( + opts.labels, + agentId === 'main' ? undefined : opts.binding?.profile, + ), }); this.onDidCreateEmitter.fire(agent); didCreate = true; @@ -209,10 +222,13 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle try { await managed.handle.dispose(); } catch { } - } else if (createdHandle !== undefined) { - try { - await createdHandle.dispose(); - } catch { } + } else { + if (createdHandle !== undefined) { + try { + await createdHandle.dispose(); + } catch { } + } + telemetryBinding.dispose(); } if (!finalizerArmed) eventBus?.deactivateAgent(agent); if (didCreate) this.onDidCloseEmitter.fire(agent); @@ -251,17 +267,17 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle }); } const source = sourceManaged.handle; + const sourceData = source.accessor.get(IAgentProfileService).data(); + const override = opts?.binding; const childContext = await this.create({ agentId: opts?.agentId, runtimeId: source.accessor.get(IAgentRuntimeBindingService).current.runtimeId, forkedFrom: source.id, - labels: opts?.labels, + labels: withSubagentProfile(opts?.labels, override?.profile ?? sourceData.profileName), }); const child = this.requireManaged(childContext).handle; - const sourceData = source.accessor.get(IAgentProfileService).data(); const childProfile = child.accessor.get(IAgentProfileService); - const override = opts?.binding; if (override?.profile !== undefined) { await childProfile.bind({ profile: override.profile, @@ -302,10 +318,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle for (const managed of this.roster.values()) { if (managed.closing || !managed.active) continue; const handle = managed.handle; - if ( - handle.accessor.get(IAgentStateService).get(profileKey).profileName === - TOWER_WORKER_PROFILE - ) { + if (hasPinnedPermissionMode(handle.accessor.get(IAgentStateService).get(profileKey).profileName)) { continue; } handle.accessor.get(IAgentPermissionModeService).setMode(mode); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/errors.ts b/packages/agent-core-v2/src/session/agentLifecycle/errors.ts index e72baaf0d78..015d4bf3f46 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/errors.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/errors.ts @@ -9,6 +9,7 @@ export const AgentLifecycleErrors = { AGENT_NOT_OWNED: 'agent.not_owned', AGENT_TYPE_NOT_ALLOWED: 'agent.type_not_allowed', AGENT_MAX_TOKENS_EXCEEDED: 'agent.max_tokens_exceeded', + AGENT_NO_FINAL_MESSAGE: 'agent.no_final_message', }, } as const satisfies ErrorDomain; 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 c55af1164e2..58095e54f3b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -7,7 +7,6 @@ import { } from '#/app/agentProfileCatalog/profile-shared'; import EXPLORE_ROLE from './explore-overlay.md?raw'; -import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw'; const AGENT_TOOLS = [ 'Read', @@ -82,14 +81,8 @@ const CODER_ROLE = 'Your final message is the entire handoff — the parent sees nothing else from your run. ' + 'Make it technically complete: what you changed and why, the path of every file you touched, ' + 'how you verified the change (tests or commands run, with results), and anything left undone ' + - 'or worth follow-up. A final message of only a sentence or two is treated as too brief and ' + - 'sent back to you for expansion, costing an extra turn.'; - -const DEFAULT_SUMMARY_POLICY = { - minChars: 200, - continuationPrompt: SUMMARY_CONTINUATION_PROMPT, - retries: 1, -} as const; + 'or worth follow-up. If you are stopped before finishing, the parent receives only what ' + + 'you have written so far, so keep the handoff current.'; registerAgentProfile({ name: 'agent', @@ -109,7 +102,6 @@ registerAgentProfile({ tools: CODER_TOOLS, renderSystemPrompt: (context) => renderSystemPromptResult(CODER_ROLE, context, { skillActive: skillActiveFor(CODER_TOOLS) }), - summaryPolicy: DEFAULT_SUMMARY_POLICY, }); registerAgentProfile({ @@ -127,5 +119,4 @@ registerAgentProfile({ return ''; } }, - summaryPolicy: DEFAULT_SUMMARY_POLICY, }); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/summary-continuation.md b/packages/agent-core-v2/src/session/agentLifecycle/profile/summary-continuation.md deleted file mode 100644 index 8efb589a590..00000000000 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/summary-continuation.md +++ /dev/null @@ -1,5 +0,0 @@ -Your previous response was too brief. Please provide a more comprehensive summary that includes: - -1. Specific technical details and implementations -2. Detailed findings and analysis -3. All important information that the parent agent should know \ No newline at end of file diff --git a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts index aa7b6000d7c..65908c6c383 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts @@ -11,6 +11,14 @@ export function subagentLabels( return labels; } +export function withSubagentProfile( + labels: Readonly> | undefined, + profileName: string | undefined, +): Readonly> | undefined { + if (profileName === undefined || profileName.length === 0) return labels; + return { ...labels, profileName }; +} + export function labelsFromAgentMeta( meta: AgentMeta, ): Readonly> | undefined { @@ -42,6 +50,11 @@ export function subagentSwarmItem(meta: AgentMeta | undefined): string | undefin return firstNonEmpty(meta.labels?.['swarmItem'], meta.swarmItem); } +export function subagentProfileName(meta: AgentMeta | undefined): string | undefined { + if (meta === undefined) return undefined; + return firstNonEmpty(meta.labels?.['profileName']); +} + function firstNonEmpty(...values: readonly (string | undefined)[]): string | undefined { return values.find((value) => value !== undefined && value.length > 0); } diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 2a4e98d54a1..30d4acebaac 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -13,7 +13,7 @@ import { isAbortError } from '#/_base/utils/abort'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IEventDispatcher } from '#/state/eventDispatcher'; -import { type AgentRunHandle, ISessionSubagentService } from './subagent'; +import { type AgentRunCompletion, type AgentRunHandle, ISessionSubagentService } from './subagent'; import type { SubagentModelSource } from './configSection'; export interface SubagentSpawnedPayload { @@ -136,7 +136,7 @@ export async function mirrorAgentRun( requester: IAgentScopeHandle, run: AgentRunHandle, options: MirrorAgentRunOptions, -): Promise<{ summary: string; usage?: TokenUsage }> { +): Promise { const dispatcher = requester.accessor.get(IEventDispatcher); const subagents = requester.accessor.get(ISessionSubagentService); const agentLifecycle = requester.accessor.get(IAgentLifecycleService); diff --git a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts index bbd42b5339a..4bb64c5f14f 100644 --- a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts @@ -1,5 +1,4 @@ import { APIProviderRateLimitError, isProviderRateLimitError } from '#/kosong/contract/errors'; -import { type TokenUsage } from '#/kosong/contract/usage'; import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; @@ -7,12 +6,16 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import { Error2, ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; +import { + IAgentLoopService, + isMaxStepsExceededError, + type Turn, + type TurnResult, +} from '#/agent/loop/loop'; import { agentContextOf } from '#/agent/scopeContext/scopeContext'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; -import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import type { AgentRunHandle, AgentRunRequest } from './subagent'; +import type { AgentRunCompletion, AgentRunHandle, AgentRunRequest } from './subagent'; export const AGENT_RUN_PROMPT_ORIGIN: PromptOrigin = { kind: 'system_trigger', @@ -22,8 +25,9 @@ export const AGENT_RUN_PROMPT_ORIGIN: PromptOrigin = { const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; +type CompletedTurnResult = Extract; + export interface RunAgentTurnOptions { - readonly summaryPolicy?: AgentProfileSummaryPolicy; readonly signal: AbortSignal; readonly onReady?: () => void; } @@ -58,32 +62,30 @@ async function awaitRun( target: IAgentScopeHandle, turn: Turn, options: RunAgentTurnOptions, -): Promise<{ summary: string; usage?: TokenUsage }> { +): Promise { const controller = new AbortController(); const unlink = linkAbortSignal(options.signal, controller); const loop = target.accessor.get(IAgentLoopService); - const cancelTurn = (turnToCancel: Turn, reason: unknown): void => { - loop.cancel(turnToCancel.id, reason); + const cancelTurn = (reason: unknown): void => { + loop.cancel(turn.id, reason); }; - let turnRef: Turn = turn; try { - const result = await awaitTurn(turnRef, controller, cancelTurn); - classifyTurnResult(result); - const summary = await distillSummary( - target, - controller, - options.summaryPolicy, - (t) => { - turnRef = t; - }, - cancelTurn, - ); + const result = classifyTurnResult(await awaitTurn(turn, controller, cancelTurn)); + const summary = latestAssistantText(target.accessor.get(IAgentContextMemoryService).get()); + const stopReason = result.stopReason; + if (summary.trim().length === 0) { + throw new Error2( + ErrorCodes.AGENT_NO_FINAL_MESSAGE, + noFinalMessageError(stopReason), + stopReason === undefined ? undefined : { details: { stopReason } }, + ); + } const usage = target.accessor.get(ISessionUsageService)?.status(agentContextOf(target)).total; - return { summary, usage }; + return { summary, usage, stopReason }; } finally { unlink(); if (controller.signal.aborted) { - cancelTurn(turnRef, controller.signal.reason); + cancelTurn(controller.signal.reason); } } } @@ -91,10 +93,10 @@ async function awaitRun( async function awaitTurn( turn: Turn, controller: AbortController, - cancelTurn: (turn: Turn, reason: unknown) => void, + cancelTurn: (reason: unknown) => void, ): Promise { const cancelOnAbort = (): void => { - cancelTurn(turn, controller.signal.reason); + cancelTurn(controller.signal.reason); }; controller.signal.addEventListener('abort', cancelOnAbort, { once: true }); try { @@ -109,48 +111,13 @@ async function awaitTurn( } } -async function distillSummary( - target: IAgentScopeHandle, - controller: AbortController, - policy: AgentProfileSummaryPolicy | undefined, - setTurn: (turn: Turn) => void, - cancelTurn: (turn: Turn, reason: unknown) => void, -): Promise { - const memory = target.accessor.get(IAgentContextMemoryService); - let summary = latestAssistantText(memory.get()); - if (policy === undefined) return summary; - if (isSummaryAdequate(summary, policy)) return summary; - - const promptService = target.accessor.get(IAgentPromptService); - for (let attempt = 0; attempt < policy.retries; attempt++) { - const turn = await (await promptService.enqueue({ message: { - role: 'user', - content: [{ type: 'text', text: policy.continuationPrompt }], - toolCalls: [], - origin: AGENT_RUN_PROMPT_ORIGIN, - } })).launched; - if (turn === undefined) break; - setTurn(turn); - const result = await awaitTurn(turn, controller, cancelTurn); - classifyTurnResult(result); - const continued = latestAssistantText(memory.get()); - if (continued.trim().length > 0) summary = continued; - if (isSummaryAdequate(summary, policy)) break; - } - return summary; -} - -function isSummaryAdequate(summary: string, policy: AgentProfileSummaryPolicy): boolean { - return summary.trim().length >= policy.minChars; -} - -function classifyTurnResult(result: TurnResult): void { +function classifyTurnResult(result: TurnResult): CompletedTurnResult { switch (result.type) { case 'completed': if (result.truncated) { throw new Error2(ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED, SUBAGENT_MAX_TOKENS_ERROR); } - return; + return result; case 'failed': { const error = result.error; if (isProviderRateLimitError(error)) throw error; @@ -158,6 +125,9 @@ function classifyTurnResult(result: TurnResult): void { if (payload.code === ErrorCodes.PROVIDER_RATE_LIMIT) { throw providerRateLimitErrorFromPayload(payload); } + if (isMaxStepsExceededError(error)) { + throw maxStepsErrorFromPayload(payload); + } throw toRunError(error); } case 'cancelled': @@ -165,6 +135,21 @@ function classifyTurnResult(result: TurnResult): void { } } +function noFinalMessageError(stopReason: string | undefined): string { + const base = 'Subagent turn ended without a final message'; + return stopReason === undefined ? `${base}.` : `${base} (stop reason: ${stopReason}).`; +} + +function maxStepsErrorFromPayload(payload: KimiErrorPayload): Error2 { + const maxSteps = payload.details?.['maxSteps']; + const cap = typeof maxSteps === 'number' ? ` (maxSteps=${String(maxSteps)})` : ''; + return new Error2( + ErrorCodes.LOOP_MAX_STEPS_EXCEEDED, + `Subagent hit the per-turn step cap${cap} before finishing its handoff.`, + typeof maxSteps === 'number' ? { details: { maxSteps } } : undefined, + ); +} + function toRunError(error: unknown): Error { if (error instanceof Error) return error; if (error === undefined || error === null) return new Error('Agent turn failed'); diff --git a/packages/agent-core-v2/src/session/subagent/subagent.ts b/packages/agent-core-v2/src/session/subagent/subagent.ts index d3994abc5eb..d07baf41eab 100644 --- a/packages/agent-core-v2/src/session/subagent/subagent.ts +++ b/packages/agent-core-v2/src/session/subagent/subagent.ts @@ -1,7 +1,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; import type { TokenUsage } from '#/kosong/contract/usage'; -import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { Turn } from '#/agent/loop/loop'; import type { Hooks } from '#/hooks'; @@ -19,14 +18,19 @@ export type AgentRunRequest = export interface RunAgentOptions { readonly signal: AbortSignal; - readonly summaryPolicy?: AgentProfileSummaryPolicy; readonly onReady?: () => void; } +export interface AgentRunCompletion { + readonly summary: string; + readonly usage?: TokenUsage; + readonly stopReason?: string; +} + export interface AgentRunHandle { readonly agentId: string; readonly turn: Turn; - readonly completion: Promise<{ readonly summary: string; readonly usage?: TokenUsage }>; + readonly completion: Promise; } export interface AgentTaskStartHookContext { diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index ee2eff33112..d4b48ef51ec 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -8,7 +8,6 @@ import { registerScopedService, } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; -import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { rootDelegationExtras, @@ -87,11 +86,7 @@ export class SessionSubagentService extends Service implements ISessionSubagentS details: { agentId: agent.agentId }, }); } - return runAgentTurn(handle, request, { - summaryPolicy: opts.summaryPolicy ?? this.summaryPolicyFor(handle), - signal: opts.signal, - onReady: opts.onReady, - }); + return runAgentTurn(handle, request, { signal: opts.signal, onReady: opts.onReady }); } async planSpawn(input: SubagentSpawnPlanInput): Promise { @@ -245,12 +240,6 @@ export class SessionSubagentService extends Service implements ISessionSubagentS } return handle; } - - private summaryPolicyFor(handle: IAgentScopeHandle): AgentProfileSummaryPolicy | undefined { - const profileName = handle.accessor.get(IAgentProfileService).data().profileName; - if (profileName === undefined) return undefined; - return this.catalog.get(profileName)?.summaryPolicy; - } } registerScopedService( diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index b368b2aa3b4..430ced66938 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -33,6 +33,7 @@ export interface ExecutableToolSuccessResult { readonly output: ExecutableToolOutput; readonly isError?: false | undefined; readonly stopTurn?: boolean | undefined; + readonly stopTurnReason?: string; readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; @@ -44,6 +45,7 @@ export interface ExecutableToolErrorResult { readonly output: ExecutableToolOutput; readonly isError: true; readonly stopTurn?: boolean | undefined; + readonly stopTurnReason?: string; readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index d43ae00c0fa..19167054602 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -34,6 +34,7 @@ import { CHILD_SESSION_KIND, } from '#/app/sessionIndex/sessionIndex'; import { buildSessionSummary } from '#/app/sessionIndex/sessionIndexSource'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { bindTelemetryScope } from '#/app/telemetry/telemetryService'; import { ErrorCodes, Error2, isError2 } from '#/errors'; import { IHostFileSystem, type HostDirEntry } from '#/os/interface/hostFileSystem'; import { @@ -255,38 +256,51 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }; - const handle = createScopedChildHandle( - this.instantiation, - LifecycleScope.Session, - opts.sessionId, - { - seeds: [ - ...sessionContextSeed(ctx), - [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], - ...sessionAgentProfileCatalogSeed({ - _serviceBrand: undefined, - workspaceKey: workspaceId, - }), - [ISessionSkillCatalogData, this.workspaceSkillCatalog.sessionData()], - [ISessionInstructionsProvider, this.workspaceInstructions.sessionProvider()], - [ISessionMcpHandle, this.workspaceMcp.sessionHandle()], - [ISessionWorkspaceInfo, this.workspaceDirs.sessionInfo()], - ...sessionEphemeralMcpServersSeed(opts.mcpServers ?? {}), - ], - configureContainer: (container) => { - 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'); - }, - }); + const telemetryBinding = bindTelemetryScope(this.telemetry, { + session_id: opts.sessionId, + }); + let handle: ISessionScopeHandle; + try { + handle = createScopedChildHandle( + this.instantiation, + LifecycleScope.Session, + opts.sessionId, + { + seeds: [ + ...sessionContextSeed(ctx), + [ITelemetryService, telemetryBinding.telemetry], + ...sessionAgentProfileCatalogSeed({ + _serviceBrand: undefined, + workspaceKey: workspaceId, + }), + [ISessionSkillCatalogData, this.workspaceSkillCatalog.sessionData()], + [ISessionInstructionsProvider, this.workspaceInstructions.sessionProvider()], + [ISessionMcpHandle, this.workspaceMcp.sessionHandle()], + [ISessionWorkspaceInfo, this.workspaceDirs.sessionInfo()], + ...sessionEphemeralMcpServersSeed(opts.mcpServers ?? {}), + ], + configureContainer: (container) => { + container.anchorKernelEntry( + () => telemetryBinding.dispose(), + 'telemetry:session-context', + ); + 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; + ) as ISessionScopeHandle; + } catch (error) { + telemetryBinding.dispose(); + throw error; + } try { await handle.accessor.get(ISessionMetadata).ready; await handle.accessor.get(ISessionToolPolicy).ready; @@ -338,7 +352,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const promise = this.doResume(sessionId, opts) .catch((error: unknown) => { this.telemetry - .withContext({ sessionId }) + .withContext({ session_id: sessionId }) .track2('session_load_failed', { reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', }); @@ -407,7 +421,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec void handle.dispose(); await drainLogCloses(); this._onDidCloseSession.fire({ sessionId }); - this.telemetry.withContext({ sessionId }).track2('session_ended', { reason: 'exit' }); + this.telemetry.withContext({ session_id: sessionId }).track2('session_ended', { reason: 'exit' }); } async archive(sessionId: string): Promise { @@ -429,7 +443,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec void handle.dispose(); await drainLogCloses(); this._onDidArchiveSession.fire({ sessionId }); - this.telemetry.withContext({ sessionId }).track2('session_ended', { reason: 'archive' }); + this.telemetry.withContext({ session_id: sessionId }).track2('session_ended', { reason: 'archive' }); } async restore( 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 2635a4604a6..cbe017666d1 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -53,10 +53,7 @@ import { OrderedHookSlot } from '#/hooks'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; -import { - AgentAgentsMdReminderService, - agentsMdReminderPendingKey, -} from '#/agent/agentsMdReminder/agentsMdReminderService'; +import { AgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminderService'; import { extractBashTargetDirs } from '#/agent/agentsMdReminder/bashTargets'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; @@ -371,7 +368,7 @@ describe('agentsMdReminder instructions change announcements', () => { expect(h.reminders).toHaveLength(0); }); - it('adds announced created paths to the pending set so the reminder re-injects until read', async () => { + it('reminds an announced created path on the next access to its directory', async () => { const h = createHarness(); const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); h.reminder.seedInjected([], workDir); @@ -379,8 +376,11 @@ describe('agentsMdReminder instructions change announcements', () => { h.instructionsChange.fire([{ path: rootAgentsMd, action: 'created', kind: 'file' }]); expect(h.reminders).toHaveLength(1); - const pending = h.ix.get(IAgentStateService).get(agentsMdReminderPendingKey); - expect(pending.has(normalize(rootAgentsMd))).toBe(true); + expect(agentsMdMessages(h)).toHaveLength(0); + + await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(rootAgentsMd); }); }); @@ -422,7 +422,7 @@ describe('agentsMdReminder path-carrying tools', () => { expect(reminderText(h)).toContain(subAgentsMd); }); - it('marks an AGENTS.md known when read directly and never suggests it afterwards', async () => { + it('does not queue the file read in the triggering call, but re-reminds on a later access', async () => { const h = createHarness(); const subDir = join(workDir, 'packages', 'kap-server'); const subAgentsMd = await writeAgentsMd(subDir); @@ -433,7 +433,8 @@ describe('agentsMdReminder path-carrying tools', () => { const after = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(agentsMdMessages(h)).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('discovers the .kimi-code/AGENTS.md variant alongside the plain one', async () => { @@ -519,7 +520,7 @@ describe('agentsMdReminder re-injection after context loss', () => { expect(reminderText(h)).toContain(subAgentsMd); }); - it('keeps directly-read paths silent across compaction', async () => { + it('re-reminds a directly-read path on access after compaction drops the read content', async () => { const h = createHarness(); const subDir = join(workDir, 'packages', 'kap-server'); const subAgentsMd = await writeAgentsMd(subDir); @@ -531,7 +532,8 @@ describe('agentsMdReminder re-injection after context loss', () => { compact(h); await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(agentsMdMessages(h)).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('keeps injected paths silent across compaction', async () => { @@ -579,7 +581,7 @@ describe('agentsMdReminder re-injection after context loss', () => { expect(agentsMdMessages(h)).toHaveLength(0); }); - it('re-reminds a pending path after an undo removes the reminder', async () => { + it('re-reminds a pending path on the next access after an undo removes the reminder', async () => { const h = createHarness(); const subDir = join(workDir, 'packages', 'kap-server'); const subAgentsMd = await writeAgentsMd(subDir); @@ -597,6 +599,9 @@ describe('agentsMdReminder re-injection after context loss', () => { expect(agentsMdMessages(h)).toHaveLength(0); await h.step(); + expect(agentsMdMessages(h)).toHaveLength(0); + + await fire(h, didCtx('Read', { path: join(subDir, 'other.ts') })); expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); }); @@ -632,7 +637,7 @@ describe('agentsMdReminder re-injection after context loss', () => { expect(messageText(messages[1]!)).not.toContain(agentsMdA); }); - it('re-reminds a created-and-announced path after compaction', async () => { + it('re-reminds a created-and-announced path on the next access after compaction', async () => { const h = createHarness(); const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); h.reminder.seedInjected([], workDir); @@ -640,16 +645,27 @@ describe('agentsMdReminder re-injection after context loss', () => { h.instructionsChange.fire([{ path: rootAgentsMd, action: 'created', kind: 'file' }]); expect(h.reminders).toHaveLength(1); - await h.step(); - expect(agentsMdMessages(h)).toHaveLength(1); - compact(h); - expect(agentsMdMessages(h)).toHaveLength(0); - await h.step(); + await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(rootAgentsMd); }); + + it('does not re-remind at a bare step after compaction without a new access', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + + compact(h); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(0); + }); }); describe('agentsMdReminder Bash coverage', () => { @@ -938,7 +954,7 @@ describe('agentsMdReminder probing boundaries', () => { expect(reminderText(h)).toContain(subAgentsMd); }); - it('marks an AGENTS.md known when it is written directly', async () => { + it('does not queue a directly written file, but re-reminds on a later access', async () => { const h = createHarness(); const subDir = join(workDir, 'packages', 'kap-server'); await mkdir(subDir, { recursive: true }); @@ -948,9 +964,11 @@ describe('agentsMdReminder probing boundaries', () => { expect(outputText(written)).toBe('original result'); expect(agentsMdMessages(h)).toHaveLength(0); + await writeAgentsMd(subDir); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(agentsMdMessages(h)).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(agentsMdPath); }); it('reminds at most once for two parallel touches of the same directory', async () => { @@ -968,6 +986,78 @@ describe('agentsMdReminder probing boundaries', () => { expect(agentsMdMessages(h)).toHaveLength(1); }); + it('deduplicates staggered same-step completions that discover the same file', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-a' }), + ); + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'b.ts') }, { id: 'call-b' }), + ); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); + expect(h.telemetryEvents.filter((e) => e.event === 'agents_md_reminder_shown')).toHaveLength(1); + }); + + it('suppresses a queued reminder when a sibling call reads the file directly', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-a' }), + ); + await h.events.didExecuteSlot.run( + didCtx('Read', { path: subAgentsMd }, { id: 'call-b' }), + ); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(0); + }); + + it('suppresses a reminder when the direct read completes before the sibling access', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await h.events.didExecuteSlot.run( + didCtx('Read', { path: subAgentsMd }, { id: 'call-read' }), + ); + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-access' }), + ); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(0); + + await fire(h, didCtx('Read', { path: join(subDir, 'b.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); + }); + + it('drops a queued reminder when the file is deleted before the step head', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-a' }), + ); + h.instructionsChange.fire([{ path: subAgentsMd, action: 'deleted', kind: 'file' }]); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(0); + }); + it('re-judges the project root at a nested repository', async () => { const h = createHarness(); const nested = join(workDir, 'packages', 'nested'); 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 905ac59eca5..85292ea6736 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -30,6 +30,7 @@ import { IAgentFullCompactionService, IModelOAuthTokens, IAgentProfileService, + ITelemetryService, IAgentToolRegistryService, DYNAMIC_TOOL_SCHEMA_VARIANT, normalizeAgentProfile, @@ -40,7 +41,6 @@ import { import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentTodoService } from '#/features/todo/todoService'; import { IAgentGoalService } from '#/features/goal/goalService'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; type GenerateFn = NonNullable; @@ -1081,6 +1081,10 @@ describe('FullCompaction', () => { agent_id: 'main', from: 'compacting', trace_id: 'trace-compact-retry', + mode: 'agent', + model: 'kimi-code', + protocol: 'openai', + provider_type: 'kimi', }, }); vi.useRealTimers(); @@ -1184,7 +1188,7 @@ describe('FullCompaction', () => { }); ctx.appendExchange(1, 'old user one', 'old assistant one', 20); ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); - ctx.get(IAgentTelemetryContextService).set({ trace_id: 'trace-turn-1' }); + ctx.get(ITelemetryService).setContext({ trace_id: 'trace-turn-1' }); const failed = ctx.once('error'); await ctx.rpc.beginCompaction({}); @@ -1199,7 +1203,7 @@ describe('FullCompaction', () => { trace_id: 'trace-mid-stream', }), }); - expect(ctx.get(IAgentTelemetryContextService).get().trace_id).toBe('trace-turn-1'); + expect(ctx.get(ITelemetryService).getContext().trace_id).toBe('trace-turn-1'); await ctx.expectResumeMatches(); }); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index f8b94b833d7..c6e9d729afd 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -272,6 +272,7 @@ function createService( dispatcher: ix.get(IEventDispatcher), records, events, + telemetry, telemetryRecords, measuredCalls, }; @@ -892,6 +893,75 @@ describe('AgentLLMRequesterService trace id', () => { telemetryRecords.find((record) => record.event === 'api_error')?.properties?.['trace_id'], ).toBeUndefined(); }); + + it('mirrors the request trace into the ambient telemetry context', async () => { + const { service, telemetry } = createService( + createTracedRequester('trace-ambient-1'), + passthroughProjector, + ); + + await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); + + expect(telemetry.getContext()['trace_id']).toBe('trace-ambient-1'); + }); + + it('clears the ambient trace when the next turn request starts without one', async () => { + let nextTrace: string | null = 'trace-ambient-2'; + const requester = createTracedRequester(null); + Object.defineProperty(requester, 'request', { + value: async function* (_input: unknown, _signal: unknown, requestOptions: { + onTraceId?: (traceId: string | null) => void; + }) { + requestOptions?.onTraceId?.(nextTrace); + yield { + type: 'finish', + message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }], toolCalls: [] }, + providerFinishReason: 'completed', + rawFinishReason: 'stop', + id: 'resp-1', + traceId: nextTrace ?? undefined, + } satisfies ModelRequestEvent; + }, + }); + const { service, telemetry } = createService(requester, passthroughProjector); + + await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); + expect(telemetry.getContext()['trace_id']).toBe('trace-ambient-2'); + + nextTrace = null; + await service.request({ source: { type: 'turn', turnId: 1, step: 2 } }); + expect(telemetry.getContext()['trace_id']).toBeUndefined(); + }); + + it('mirrors the failing request trace into the ambient telemetry context', async () => { + const requester = createTracedRequester(null); + Object.defineProperty(requester, 'request', { + value: async function* () { + const events: ModelRequestEvent[] = []; + for (const event of events) yield event; + throw new APIStatusError(500, 'boom', 'req-1', null, 'trace-fail-ambient'); + }, + }); + const { service, telemetry } = createService(requester, passthroughProjector); + + await expect( + service.request({ source: { type: 'turn', turnId: 1, step: 1 } }), + ).rejects.toMatchObject({ statusCode: 500 }); + + expect(telemetry.getContext()['trace_id']).toBe('trace-fail-ambient'); + }); + + it('keeps the ambient trace untouched for operation requests', async () => { + const { service, telemetry } = createService( + createTracedRequester('trace-operation-1'), + passthroughProjector, + ); + telemetry.setContext({ trace_id: 'trace-turn-1' }); + + await service.request({ source: { type: 'operation', requestKind: 'full_compaction' } }); + + expect(telemetry.getContext()['trace_id']).toBe('trace-turn-1'); + }); }); describe('AgentLLMRequesterService media resolver wiring', () => { 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 346d6908bb4..afd2147123f 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -24,6 +24,7 @@ import { RetryStepRequest } from '#/agent/prompt/promptStepRequests'; import type { ExecutableTool } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IEventBus } from '#/app/event/eventBus'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import { userCancellationReason } from '#/_base/utils/abort'; import { @@ -631,6 +632,48 @@ describe('Agent loop', () => { }); }); + it('carries a tool stopTurnReason into the completed turn result and turn.ended', async () => { + const stopCall: ToolCall = { + type: 'function', + id: 'call_stop', + name: 'Stopper', + arguments: '{}', + }; + const stopperTool: ExecutableTool> = { + name: 'Stopper', + description: 'Stops the turn with a reason.', + parameters: { type: 'object', properties: {}, additionalProperties: false }, + resolveExecution: () => ({ + approvalRule: 'Stopper', + execute: async () => ({ output: 'stopped', stopTurn: true, stopTurnReason: 'demo_reason' }), + }), + }; + profile.update({ activeToolNames: ['Stopper'] }); + ctx.get(IAgentToolRegistryService).register(stopperTool); + + ctx.mockNextResponse({ type: 'text', text: 'Stopping.' }, stopCall); + ctx.mockNextResponse({ type: 'text', text: 'This step should not run.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'stop' }] }); + const turn = (loop as unknown as { activeTurnJob?: { turn: Turn } }).activeTurnJob?.turn; + await ctx.untilApproval(true); + await ctx.untilTurnEnd(); + + expect(ctx.llmCalls).toHaveLength(1); + await expect(turn!.result).resolves.toEqual({ + type: 'completed', + steps: 1, + truncated: false, + stopReason: 'demo_reason', + }); + const turnEnded = ctx.allEvents.find( + (event) => event.type === '[rpc]' && event.event === 'turn.ended', + ); + expect(turnEnded?.args).toMatchObject({ reason: 'completed', stopReason: 'demo_reason' }); + const record = (await ctx.persistedWireRecords()).find((entry) => entry.type === 'turn.ended'); + expect(record).toMatchObject({ turnId: 0, reason: 'completed', stopReason: 'demo_reason' }); + }); + it('queues consecutive nextTurn requests in FIFO order without overlapping turns', async () => { const events: string[] = []; const subscription = ctx.get(IEventBus).subscribe((event) => { @@ -1018,6 +1061,7 @@ describe('turn telemetry', () => { turn_id: 0, agent_id: 'main', mode: 'agent', + model: 'mock-model', provider_type: 'kimi', protocol: 'openai', thinking_effort: 'off', @@ -1110,6 +1154,24 @@ describe('turn telemetry', () => { } }); + it('clears the ambient trace id when the turn ends', async () => { + const records: TelemetryRecord[] = []; + const local = createTestAgent({ telemetry: recordingTelemetry(records) }); + try { + local.get(IAgentProfileService).update({ activeToolNames: [] }); + local.mockNextProviderResponse({ + parts: [{ type: 'text', text: 'hi' }], + traceId: 'trace-turn-clear', + }); + await local.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); + await local.untilTurnEnd(); + + expect(local.get(ITelemetryService).getContext()['trace_id']).toBeUndefined(); + } finally { + await local.dispose(); + } + }); + it('does not reuse the previous step trace when a step hook fails before a request', async () => { const records: TelemetryRecord[] = []; const local = createTestAgent({ telemetry: recordingTelemetry(records) }); diff --git a/packages/agent-core-v2/test/agent/mcp/output.test.ts b/packages/agent-core-v2/test/agent/mcp/output.test.ts index 335e8613514..d2a19dcbba0 100644 --- a/packages/agent-core-v2/test/agent/mcp/output.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts @@ -36,15 +36,14 @@ interface TelemetryRecord { function recordingTelemetry(records: TelemetryRecord[]): ITelemetryService { const telemetry: ITelemetryService = { _serviceBrand: undefined, - track(event, properties) { - records.push({ event, properties }); + track2(event, properties) { + records.push({ event, properties: properties as TelemetryProperties }); }, - track2: (event, properties) => telemetry.track(event, properties as TelemetryProperties), withContext: () => telemetry, setContext: () => {}, + getContext: () => ({}), addAppender: () => ({ dispose: () => {} }), removeAppender: () => {}, - setAppender: () => {}, setEnabled: () => {}, flush: async () => {}, shutdown: async () => {}, diff --git a/packages/agent-core-v2/test/agent/media/image-compress.test.ts b/packages/agent-core-v2/test/agent/media/image-compress.test.ts index 37e983ee3f3..e7d7c8fdf66 100644 --- a/packages/agent-core-v2/test/agent/media/image-compress.test.ts +++ b/packages/agent-core-v2/test/agent/media/image-compress.test.ts @@ -18,8 +18,8 @@ import { resolveReadImageByteBudget, setConfiguredMaxImageEdgePx, setConfiguredReadImageByteBudget, - type ImageCompressionTelemetryClient, } from '#/agent/media/image-compress'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; import { sniffImageDimensions } from '#/agent/media/file-type'; import { normalizeImageMime, @@ -1263,20 +1263,23 @@ interface CapturedEvent { readonly props: Readonly>; } -function captureTelemetry(): { client: ImageCompressionTelemetryClient; events: CapturedEvent[] } { +function captureTelemetry(): { telemetry: ITelemetryService; events: CapturedEvent[] } { const events: CapturedEvent[] = []; return { - client: { track: (event, props) => events.push({ event, props: props ?? {} }) }, + telemetry: { + track2: (event: string, props: unknown) => events.push({ event, props: (props ?? {}) as CapturedEvent['props'] }), + } as unknown as ITelemetryService, events, }; } describe('compressImageForModel — telemetry', () => { it('reports a compressed image with sizes, formats, and duration', async () => { - const { client, events } = captureTelemetry(); + const { telemetry, events } = captureTelemetry(); const png = await solidPng(2100, 1050); const result = await compressImageForModel(png, 'image/png', { - telemetry: { client, source: 'read_media' }, + telemetry, + telemetrySource: 'read_media', }); expect(result.changed).toBe(true); @@ -1298,9 +1301,10 @@ describe('compressImageForModel — telemetry', () => { }); it('reports the fast path as passthrough_fast', async () => { - const { client, events } = captureTelemetry(); + const { telemetry, events } = captureTelemetry(); await compressImageForModel(await solidPng(64, 64), 'image/png', { - telemetry: { client, source: 'tui_paste' }, + telemetry, + telemetrySource: 'tui_paste', }); expect(events).toHaveLength(1); expect(events[0]!.props['outcome']).toBe('passthrough_fast'); @@ -1317,14 +1321,14 @@ describe('compressImageForModel — telemetry', () => { const bomb = captureTelemetry(); await compressImageForModel(new Uint8Array(header), 'image/png', { - telemetry: { client: bomb.client, source: 'mcp_tool_result' }, + telemetry: bomb.telemetry, telemetrySource: 'mcp_tool_result', }); expect(bomb.events[0]!.props['outcome']).toBe('passthrough_guard'); const byteCap = captureTelemetry(); await compressImageForModel(await solidPng(2100, 100), 'image/png', { maxDecodeBytes: 64, - telemetry: { client: byteCap.client, source: 'mcp_tool_result' }, + telemetry: byteCap.telemetry, telemetrySource: 'mcp_tool_result', }); expect(byteCap.events[0]!.props['outcome']).toBe('passthrough_guard'); }); @@ -1334,19 +1338,19 @@ describe('compressImageForModel — telemetry', () => { await compressImageForModel( new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]), 'image/gif', - { telemetry: { client: gif.client, source: 'mcp_tool_result' } }, + { telemetry: gif.telemetry, telemetrySource: 'mcp_tool_result' }, ); expect(gif.events[0]!.props['outcome']).toBe('passthrough_unsupported'); const empty = captureTelemetry(); await compressImageForModel(new Uint8Array(0), 'image/png', { - telemetry: { client: empty.client, source: 'mcp_tool_result' }, + telemetry: empty.telemetry, telemetrySource: 'mcp_tool_result', }); expect(empty.events[0]!.props['outcome']).toBe('passthrough_unsupported'); }); it('reports undecodable bytes as passthrough_error', async () => { - const { client, events } = captureTelemetry(); + const { telemetry, events } = captureTelemetry(); const corrupt = Buffer.alloc(32); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(corrupt, 0); corrupt.writeUInt32BE(13, 8); @@ -1354,28 +1358,31 @@ describe('compressImageForModel — telemetry', () => { corrupt.writeUInt32BE(4000, 16); corrupt.writeUInt32BE(4000, 20); await compressImageForModel(new Uint8Array(corrupt), 'image/png', { - telemetry: { client, source: 'prompt_inline' }, + telemetry, + telemetrySource: 'prompt_inline', }); expect(events[0]!.props['outcome']).toBe('passthrough_error'); }); it('marks EXIF-transposed inputs', async () => { - const { client, events } = captureTelemetry(); + const { telemetry, events } = captureTelemetry(); const jpeg = withExifOrientation(await solidJpeg(120, 80), 6); await compressImageForModel(jpeg, 'image/jpeg', { maxEdge: 64, - telemetry: { client, source: 'read_media' }, + telemetry, + telemetrySource: 'read_media', }); expect(events[0]!.props['outcome']).toBe('compressed'); expect(events[0]!.props['exif_transposed']).toBe(true); }); it('reports the base64 early size-skip as passthrough_guard', async () => { - const { client, events } = captureTelemetry(); + const { telemetry, events } = captureTelemetry(); const base64 = Buffer.from(await solidPng(2100, 100)).toString('base64'); await compressBase64ForModel(base64, 'image/png', { maxDecodeBytes: 64, - telemetry: { client, source: 'prompt_file' }, + telemetry, + telemetrySource: 'prompt_file', }); expect(events).toHaveLength(1); expect(events[0]!.props['outcome']).toBe('passthrough_guard'); @@ -1383,11 +1390,12 @@ describe('compressImageForModel — telemetry', () => { }); it('threads telemetry through compressImageContentParts', async () => { - const { client, events } = captureTelemetry(); + const { telemetry, events } = captureTelemetry(); const big = await solidPng(2100, 1050); const url = `data:image/png;base64,${Buffer.from(big).toString('base64')}`; await compressImageContentParts([{ type: 'image_url', imageUrl: { url } }], { - telemetry: { client, source: 'mcp_tool_result' }, + telemetry, + telemetrySource: 'mcp_tool_result', }); expect(events).toHaveLength(1); expect(events[0]!.event).toBe('image_compress'); @@ -1396,14 +1404,15 @@ describe('compressImageForModel — telemetry', () => { }); it('never lets a throwing telemetry client break compression', async () => { - const throwing: ImageCompressionTelemetryClient = { - track: () => { + const throwing = { + track2: () => { throw new Error('sink down'); }, - }; + } as unknown as ITelemetryService; const png = await solidPng(2100, 1050); const result = await compressImageForModel(png, 'image/png', { - telemetry: { client: throwing, source: 'read_media' }, + telemetry: throwing, + telemetrySource: 'read_media', }); expect(result.changed).toBe(true); }); @@ -1411,13 +1420,13 @@ describe('compressImageForModel — telemetry', () => { describe('cropImageForModel — telemetry', () => { it('reports a successful crop with the region share of the original', async () => { - const { client, events } = captureTelemetry(); + const { telemetry, events } = captureTelemetry(); const png = await solidPng(1000, 500); const outcome = await cropImageForModel( png, 'image/png', { x: 0, y: 0, width: 500, height: 250 }, - { telemetry: { client, source: 'read_media' } }, + { telemetry, telemetrySource: 'read_media' }, ); expect(outcome.ok).toBe(true); @@ -1440,7 +1449,7 @@ describe('cropImageForModel — telemetry', () => { await solidPng(100, 100), 'image/png', { x: 200, y: 0, width: 10, height: 10 }, - { telemetry: { client: oob.client, source: 'read_media' } }, + { telemetry: oob.telemetry, telemetrySource: 'read_media' }, ); expect(oob.events[0]!.props['ok']).toBe(false); expect(oob.events[0]!.props['error_kind']).toBe('out_of_bounds'); @@ -1450,7 +1459,7 @@ describe('cropImageForModel — telemetry', () => { new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]), 'image/gif', { x: 0, y: 0, width: 1, height: 1 }, - { telemetry: { client: format.client, source: 'read_media' } }, + { telemetry: format.telemetry, telemetrySource: 'read_media' }, ); expect(format.events[0]!.props['error_kind']).toBe('unsupported_format'); @@ -1459,7 +1468,7 @@ describe('cropImageForModel — telemetry', () => { await noisePng(400, 400), 'image/png', { x: 0, y: 0, width: 400, height: 400 }, - { skipResize: true, byteBudget: 8 * 1024, telemetry: { client: budget.client, source: 'read_media' } }, + { skipResize: true, byteBudget: 8 * 1024, telemetry: budget.telemetry, telemetrySource: 'read_media' }, ); expect(budget.events[0]!.props['error_kind']).toBe('budget'); }); diff --git a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts index d1c2def53d4..cae67fc1dc3 100644 --- a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts +++ b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts @@ -106,15 +106,14 @@ interface TelemetryRecord { function recordingTelemetry(records: TelemetryRecord[]): ITelemetryService { const telemetry: ITelemetryService = { _serviceBrand: undefined, - track(event, properties) { - records.push({ event, properties }); + track2(event, properties) { + records.push({ event, properties: properties as TelemetryProperties }); }, - track2: (event, properties) => telemetry.track(event, properties as TelemetryProperties), withContext: () => telemetry, setContext: () => {}, + getContext: () => ({}), addAppender: () => ({ dispose: () => {} }), removeAppender: () => {}, - setAppender: () => {}, setEnabled: () => {}, flush: async () => {}, shutdown: async () => {}, diff --git a/packages/agent-core-v2/test/agent/permissionMode/setModeAndBroadcast.test.ts b/packages/agent-core-v2/test/agent/permissionMode/setModeAndBroadcast.test.ts index 41a5b233695..cbe59eb605c 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/setModeAndBroadcast.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/setModeAndBroadcast.test.ts @@ -24,7 +24,10 @@ describe('setModeAndBroadcast', () => { await ctx.rpc.setPermission({ mode: 'auto' }); expect(ctx.get(IAgentPermissionModeService).mode).toBe('auto'); - expect(records).toContainEqual({ event: 'afk_toggle', properties: { agent_id: 'main', enabled: true } }); + expect(records).toContainEqual({ + event: 'afk_toggle', + properties: { agent_id: 'main', enabled: true, mode: 'agent', model: 'mock-model', protocol: 'openai', provider_type: 'kimi' }, + }); }); it('tracks the yolo toggle on enter and exit', async () => { @@ -35,7 +38,13 @@ describe('setModeAndBroadcast', () => { await ctx.rpc.setPermission({ mode: 'manual' }); expect(ctx.get(IAgentPermissionModeService).mode).toBe('manual'); - expect(records).toContainEqual({ event: 'yolo_toggle', properties: { agent_id: 'main', enabled: true } }); - expect(records).toContainEqual({ event: 'yolo_toggle', properties: { agent_id: 'main', enabled: false } }); + expect(records).toContainEqual({ + event: 'yolo_toggle', + properties: { agent_id: 'main', enabled: true, mode: 'agent', model: 'mock-model', protocol: 'openai', provider_type: 'kimi' }, + }); + expect(records).toContainEqual({ + event: 'yolo_toggle', + properties: { agent_id: 'main', enabled: false, mode: 'agent', model: 'mock-model', protocol: 'openai', provider_type: 'kimi' }, + }); }); }); diff --git a/packages/agent-core-v2/test/agent/profile/compact-threshold.test.ts b/packages/agent-core-v2/test/agent/profile/compact-threshold.test.ts index 8b18fae9902..8e9b80950b3 100644 --- a/packages/agent-core-v2/test/agent/profile/compact-threshold.test.ts +++ b/packages/agent-core-v2/test/agent/profile/compact-threshold.test.ts @@ -13,8 +13,6 @@ import { IConfigService } from '#/app/config/config'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { IProtocolAdapterRegistry, type Protocol } from '#/kosong/protocol/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; -import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContextService'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; @@ -44,6 +42,7 @@ function createTelemetryStub(): ITelemetryService { _serviceBrand: undefined, track: () => undefined, track2: () => undefined, + setContext: () => {}, } as unknown as ITelemetryService; } @@ -166,7 +165,6 @@ function buildHost(key: string): IAgentProfileService { host.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); host.stub(ITelemetryService, createTelemetryStub()); host.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); - host.stub(IAgentTelemetryContextService, new AgentTelemetryContextService()); host.stub(IConfigService, createConfigStub()); host.stub(IModelCatalog, createModelCatalogStub(createTestModel())); host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); @@ -362,9 +360,9 @@ describe('AgentProfileService.setCompactionTokenBudget telemetry (U11)', () => { track2: (event: string, payload: Record) => { track2Calls.push([event, payload]); }, + setContext: () => {}, } as unknown as ITelemetryService); host.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); - host.stub(IAgentTelemetryContextService, new AgentTelemetryContextService()); host.stub(IConfigService, createConfigStub()); host.stub(IModelCatalog, createModelCatalogStub(createTestModel())); host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); @@ -422,10 +420,11 @@ describe('AgentProfileService.setCompactionTokenBudget telemetry (U11)', () => { spySvc.setCompactionTokenBudget(120); const matching = track2Calls.filter(([event]) => event === 'compaction_token_budget_override'); expect(matching.length).toBeGreaterThan(0); - const [event, payload] = matching[matching.length - 1]; + const last = matching[matching.length - 1] as [string, Record]; + const [event, payload] = last; expect(event).toBe('compaction_token_budget_override'); - expect(payload.action).toBe('set'); - expect(payload.tokens).toBe(120_000); + expect(payload['action']).toBe('set'); + expect(payload['tokens']).toBe(120_000); }); it('fires compaction_token_budget_override with action: clear on undefined', () => { @@ -434,9 +433,10 @@ describe('AgentProfileService.setCompactionTokenBudget telemetry (U11)', () => { spySvc.setCompactionTokenBudget(undefined); const matching = track2Calls.filter(([event]) => event === 'compaction_token_budget_override'); expect(matching.length).toBeGreaterThanOrEqual(2); - const [event, payload] = matching[matching.length - 1]; + const last = matching[matching.length - 1] as [string, Record]; + const [event, payload] = last; expect(event).toBe('compaction_token_budget_override'); - expect(payload.action).toBe('clear'); + expect(payload['action']).toBe('clear'); }); it('does not fire compaction_token_budget_override on validation reject', () => { diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index e9e949ceb88..20d4c61d4ee 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -3,13 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { ModelRecord } from '#/kosong/model/model'; import { configServices, createTestAgent, + InMemoryWireRecordPersistence, llmGenerateServices, modelProviderOptionServices, telemetryServices, + wireRecordPersistenceServices, type TestAgentContext, } from '../../harness'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; @@ -156,10 +159,96 @@ describe('ConfigState model capabilities', () => { expect(records).toContainEqual({ event: 'thinking_toggle', - properties: { agent_id: 'main', enabled: true, effort: 'low', from: 'off' }, + properties: { + agent_id: 'main', + enabled: true, + effort: 'low', + from: 'off', + mode: 'agent', + model: 'kimi-code/kimi-for-coding', + protocol: 'openai', + provider_type: 'kimi', + }, + }); + }); + + it('writes the bound model into the ambient telemetry context', () => { + kimiConfig = { + providers: { + kimi: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test/v1', + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: 'kimi', + model: 'kimi-for-coding', + maxContextSize: 1_000_000, + }, + }, + }; + + profile.update({ modelAlias: 'kimi-code/kimi-for-coding' }); + + expect(ctx.get(ITelemetryService).getContext()).toMatchObject({ + model: 'kimi-code/kimi-for-coding', + provider_type: 'kimi', + protocol: 'openai', + }); + }); + + it('keeps the alias as ambient model when the bound model does not resolve', () => { + profile.update({ modelAlias: 'ghost/model' }); + + expect(ctx.get(ITelemetryService).getContext()).toMatchObject({ + model: 'ghost/model', }); }); + it('restores the ambient model after a cold resume', async () => { + kimiConfig = { + providers: { + kimi: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test/v1', + }, + }, + models: { + 'kimi-code/kimi-for-coding': { + provider: 'kimi', + model: 'kimi-for-coding', + maxContextSize: 1_000_000, + }, + }, + }; + const resumedRecords: TelemetryRecord[] = []; + const resumed = createTestAgent( + { autoConfigure: false }, + configServices(() => kimiConfig), + llmGenerateServices((...args) => generate(...args)), + telemetryServices(recordingTelemetry(resumedRecords)), + wireRecordPersistenceServices( + new InMemoryWireRecordPersistence([ + { type: 'config.update', agentId: 'main', modelAlias: 'kimi-code/kimi-for-coding' }, + ]), + ), + ); + try { + await resumed.restorePersisted(); + + expect(resumed.get(ITelemetryService).getContext()).toMatchObject({ + model: 'kimi-code/kimi-for-coding', + provider_type: 'kimi', + protocol: 'openai', + }); + } finally { + await resumed.dispose(); + } + }); + it('does not infer Kimi capabilities from the provider catalogue', () => { kimiConfig = { providers: { diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index e3bea2767bf..80312c0053a 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -18,8 +18,6 @@ import { IConfigService } from '#/app/config/config'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { IProtocolAdapterRegistry, type Protocol } from '#/kosong/protocol/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; -import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContextService'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; @@ -52,8 +50,9 @@ const KEY = 'profile-test'; function createTelemetryStub(): ITelemetryService { return { _serviceBrand: undefined, - track: () => undefined, track2: () => undefined, + setContext: () => undefined, + getContext: () => ({}), } as unknown as ITelemetryService; } @@ -199,10 +198,6 @@ function buildHost(key: string): { host.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); host.stub(ITelemetryService, createTelemetryStub()); host.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); - host.stub( - IAgentTelemetryContextService, - new AgentTelemetryContextService(), - ); host.stub(IConfigService, createConfigStub()); host.stub(IModelCatalog, modelCatalog); host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index a0850852669..c1eece1b3f9 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -108,7 +108,7 @@ function harness(loopOptions: StubLoopOptions = { pendingTurnResult: true }) { reg.define(IEventBus, EventBusService); reg.defineInstance(IAgentReminderService, reminder); reg.define(IAgentPromptService, AgentPromptService); - reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} }); + reg.definePartialInstance(ITelemetryService, { track2: () => {} }); reg.definePartialInstance(ISessionMetadata, { read: async () => ({ id: 'test-session', createdAt: 0, updatedAt: 0, archived: false }), update: async () => {}, diff --git a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index 60a98fe55a7..e277c84b74a 100644 --- a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts @@ -322,7 +322,15 @@ describe('AgentTaskService — event emission', () => { ); expect(records).toContainEqual({ event: 'background_task_created', - properties: { agent_id: 'main', task_id: taskId, kind: 'bash' }, + properties: { + agent_id: 'main', + task_id: taskId, + kind: 'bash', + mode: 'agent', + model: 'mock-model', + protocol: 'openai', + provider_type: 'kimi', + }, }); }); @@ -344,7 +352,15 @@ describe('AgentTaskService — event emission', () => { ); expect(records).toContainEqual({ event: 'background_task_created', - properties: { agent_id: 'main', task_id: taskId, kind: 'agent' }, + properties: { + agent_id: 'main', + task_id: taskId, + kind: 'agent', + mode: 'agent', + model: 'mock-model', + protocol: 'openai', + provider_type: 'kimi', + }, }); }); diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index 4638387eaa2..dfd6368dfc9 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -133,7 +133,7 @@ describe('AgentTaskService', () => { }, }); ix.stub(IAgentContextMemoryService, stubContextMemory()); - ix.stub(ITelemetryService, { track: () => {}, track2: () => {} }); + ix.stub(ITelemetryService, { track2: () => {} }); ix.stub(IAgentToolRegistryService, { register: () => toDisposable(() => {}), }); @@ -711,7 +711,7 @@ describe('AgentTaskService', () => { }, }); ix.stub(IAgentContextMemoryService, stubContextMemory()); - ix.stub(ITelemetryService, { track: () => {}, track2: () => {} }); + ix.stub(ITelemetryService, { track2: () => {} }); ix.stub(IAgentLoopService, stubLoopWithHooks()); ix.stub(IConfigService, { get: (() => undefined) as IConfigService['get'], @@ -765,7 +765,7 @@ describe('AgentTaskService', () => { }, }); ix.stub(IAgentContextMemoryService, context); - ix.stub(ITelemetryService, { track: () => {}, track2: () => {} }); + ix.stub(ITelemetryService, { track2: () => {} }); ix.stub(IAgentLoopService, stubLoopWithHooks()); ix.stub(IConfigService, { get: (() => undefined) as IConfigService['get'], diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index 8515db2a3bc..191ac175309 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -10,7 +10,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { IHostProcessService } from '#/os/interface/hostProcess'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; @@ -24,7 +24,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; import { registerLogServices } from '../../_base/log/stubs'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; -import { stubLoopWithHooks } from '../loop/stubs'; +import { stubLoopWithHooks, type StubLoop } from '../loop/stubs'; import { stubToolExecutorEvents } from '../toolExecutor/stubs'; import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs'; import { registerTestAgentWireServices } from '../../wire/stubs'; @@ -53,7 +53,7 @@ afterEach(() => disposables.dispose()); interface Harness { readonly ix: TestInstantiationService; - readonly loop: IAgentLoopService; + readonly loop: StubLoop; readonly executor: IAgentToolExecutorService; readonly registry: IAgentToolRegistryService; readonly fireBefore: ( @@ -673,6 +673,91 @@ describe('AgentToolDedupeService', () => { }); }); + describe('repeat breaker handoff step', () => { + const { REPEAT_BREAKER_STOP_REASON, HANDOFF_VETO_TEXT } = toolDedupeTesting; + + async function runStreak(h: Harness, count: number): Promise { + let last: ToolResult | undefined; + for (let i = 0; i < count; i += 1) { + const [result] = await runStep(h, 1, i + 1, [toolCall(`c${String(i)}`, 'Read', { p: 1 })]); + last = result!.result; + } + return last!; + } + + function drainHandoff(h: Harness): string | undefined { + return h.loop.drainNextBatch({ append: () => {} })?.driver.kind; + } + + it('tags the force-stop result with the repeat_breaker stop reason', async () => { + const h = createHarness(); + h.registry.register(new EchoTool('Read')); + const last = await runStreak(h, 12); + expect(last.stopTurn).toBe(true); + expect(last.stopTurnReason).toBe(REPEAT_BREAKER_STOP_REASON); + }); + + it('enqueues a single handoff step after the force stop', async () => { + const h = createHarness(); + h.registry.register(new EchoTool('Read')); + await runStreak(h, 11); + expect(h.loop.queue.hasPendingRequests()).toBe(false); + await runStep(h, 1, 12, [toolCall('c11', 'Read', { p: 1 })]); + expect(drainHandoff(h)).toBe('handoff'); + expect(h.loop.queue.hasPendingRequests()).toBe(false); + }); + + it('vetoes tool calls during the handoff step and ends the turn with the same reason', async () => { + const h = createHarness(); + const tool = new EchoTool('Read'); + h.registry.register(tool); + await runStreak(h, 12); + expect(drainHandoff(h)).toBe('handoff'); + + const [vetoed] = await runStep(h, 1, 13, [toolCall('c12', 'Read', { p: 2 })]); + expect(vetoed!.result).toMatchObject({ + isError: true, + stopTurn: true, + stopTurnReason: REPEAT_BREAKER_STOP_REASON, + }); + expect(vetoed!.result.output as string).toContain(HANDOFF_VETO_TEXT); + expect(tool.calls).toHaveLength(12); + expect(h.loop.queue.hasPendingRequests()).toBe(false); + expect( + telemetryEvents.find((e) => e.event === 'tool_call_repeat_handoff')?.properties, + ).toMatchObject({ turn_id: 1, outcome: 'vetoed' }); + expect( + telemetryEvents.filter( + (e) => e.event === 'tool_call_repeat' && e.properties?.['repeat_count'] === 13, + ), + ).toHaveLength(0); + }); + + it('records a text handoff when the model answers without tool calls', async () => { + const h = createHarness(); + h.registry.register(new EchoTool('Read')); + await runStreak(h, 12); + expect(drainHandoff(h)).toBe('handoff'); + await runStep(h, 1, 13, []); + expect(h.loop.queue.hasPendingRequests()).toBe(false); + expect( + telemetryEvents.find((e) => e.event === 'tool_call_repeat_handoff')?.properties, + ).toMatchObject({ turn_id: 1, outcome: 'text' }); + }); + + it('allows a fresh handoff in the next turn', async () => { + const h = createHarness(); + h.registry.register(new EchoTool('Read')); + await runStreak(h, 12); + expect(drainHandoff(h)).toBe('handoff'); + await runStep(h, 1, 13, []); + for (let i = 0; i < 12; i += 1) { + await runStep(h, 2, i + 1, [toolCall(`t2-${String(i)}`, 'Read', { p: 1 })]); + } + expect(drainHandoff(h)).toBe('handoff'); + }); + }); + describe('repeat telemetry', () => { it('emits same-step duplicate detection telemetry', async () => { const h = createHarness(); @@ -1027,7 +1112,10 @@ describe('AgentToolDedupeService', () => { return { type: 'function', id, name: 'Bash', arguments: `{"command_${String(variant)}: "ls"` }; } - function rejectedBashAgent(records: TelemetryRecord[]): { + function rejectedBashAgent( + records: TelemetryRecord[], + maxStepsPerTurn?: number, + ): { readonly ctx: ReturnType; readonly exec: ReturnType; } { @@ -1035,6 +1123,7 @@ describe('AgentToolDedupeService', () => { const ctx = createTestAgent( telemetryServices(recordingTelemetry(records)), execEnvServices({ processRunner: createFakeProcessRunner({ spawn: exec as unknown as IHostProcessService['spawn'] }) }), + { initialConfig: { providers: {}, loopControl: { maxStepsPerTurn } } }, ); ctx.get(IAgentProfileService).update({ activeToolNames: ['Bash'] }); records.length = 0; @@ -1048,17 +1137,100 @@ describe('AgentToolDedupeService', () => { for (let i = 0; i < 12; i += 1) { ctx.mockNextResponse(invalidBashCallWithId(`call_bad_${String(i)}`)); } + ctx.mockNextResponse({ type: 'text', text: 'Handoff: the bash call keeps failing validation.' }); ctx.mockNextResponse({ type: 'text', text: 'must never be generated' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] }); + const turn = (ctx.get(IAgentLoopService) as unknown as { activeTurnJob?: { turn: Turn } }) + .activeTurnJob?.turn; await ctx.untilTurnEnd(); expect(exec).not.toHaveBeenCalled(); - expect(ctx.llmCalls).toHaveLength(12); + expect(ctx.llmCalls).toHaveLength(13); const actions = records .filter((entry) => entry.event === 'tool_call_repeat') .map((entry) => entry.properties?.['action']); expect(actions).toEqual(['none', 'r1', 'r1', 'r2', 'r2', 'r2', 'r3', 'r3', 'r3', 'r3', 'stop']); + await expect(turn!.result).resolves.toMatchObject({ + type: 'completed', + stopReason: 'repeat_breaker', + }); + expect( + records.find((entry) => entry.event === 'tool_call_repeat_handoff')?.properties, + ).toMatchObject({ outcome: 'text' }); + }); + + it('vetoes a tool call issued during the handoff step and still ends the turn', async () => { + const records: TelemetryRecord[] = []; + const { ctx, exec } = rejectedBashAgent(records); + + for (let i = 0; i < 13; i += 1) { + ctx.mockNextResponse(invalidBashCallWithId(`call_bad_${String(i)}`)); + } + ctx.mockNextResponse({ type: 'text', text: 'must never be generated' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] }); + const turn = (ctx.get(IAgentLoopService) as unknown as { activeTurnJob?: { turn: Turn } }) + .activeTurnJob?.turn; + await ctx.untilTurnEnd(); + + expect(exec).not.toHaveBeenCalled(); + expect(ctx.llmCalls).toHaveLength(13); + await expect(turn!.result).resolves.toMatchObject({ + type: 'completed', + stopReason: 'repeat_breaker', + }); + expect( + records.find((entry) => entry.event === 'tool_call_repeat_handoff')?.properties, + ).toMatchObject({ outcome: 'vetoed' }); + expect( + records.filter( + (entry) => + entry.event === 'tool_call_repeat' && entry.properties?.['repeat_count'] === 13, + ), + ).toHaveLength(0); + }); + + it('runs the handoff step even when the force stop lands on the step cap', async () => { + const records: TelemetryRecord[] = []; + const { ctx, exec } = rejectedBashAgent(records, 12); + + for (let i = 0; i < 12; i += 1) { + ctx.mockNextResponse(invalidBashCallWithId(`call_bad_${String(i)}`)); + } + ctx.mockNextResponse({ type: 'text', text: 'Handoff: still blocked on the same call.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] }); + const turn = (ctx.get(IAgentLoopService) as unknown as { activeTurnJob?: { turn: Turn } }) + .activeTurnJob?.turn; + await ctx.untilTurnEnd(); + + expect(exec).not.toHaveBeenCalled(); + expect(ctx.llmCalls).toHaveLength(13); + await expect(turn!.result).resolves.toMatchObject({ + type: 'completed', + steps: 13, + stopReason: 'repeat_breaker', + }); + }); + + it('still enforces the step cap for ordinary steps', async () => { + const records: TelemetryRecord[] = []; + const { ctx, exec } = rejectedBashAgent(records, 12); + + for (let i = 0; i < 12; i += 1) { + ctx.mockNextResponse(malformedBashCallWithId(`call_mal_${String(i)}`, i)); + } + ctx.mockNextResponse({ type: 'text', text: 'must never be generated' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] }); + const turn = (ctx.get(IAgentLoopService) as unknown as { activeTurnJob?: { turn: Turn } }) + .activeTurnJob?.turn; + await ctx.untilTurnEnd(); + + expect(exec).not.toHaveBeenCalled(); + expect(ctx.llmCalls).toHaveLength(12); + await expect(turn!.result).resolves.toMatchObject({ type: 'failed', steps: 12 }); }); it('does not force-stop when the malformed argument text keeps changing', async () => { diff --git a/packages/agent-core-v2/test/agent/undo/undo.test.ts b/packages/agent-core-v2/test/agent/undo/undo.test.ts index e1d81a4b091..d88b161d424 100644 --- a/packages/agent-core-v2/test/agent/undo/undo.test.ts +++ b/packages/agent-core-v2/test/agent/undo/undo.test.ts @@ -17,7 +17,6 @@ import { IAgentConversationUndoService } from '#/agent/undo/undo'; import { ContextUndone } from '#/agent/undo/undoService'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { IEventBus } from '#/app/event/eventBus'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { ErrorCodes } from '#/errors'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ToolsUpdateStore } from '#/features/todo/todoOps'; @@ -28,6 +27,7 @@ import { IWireService } from '#/wire/wire'; import { createTestAgent, execEnvServices, telemetryServices, type TestAgentContext } from '../../harness'; import { createFakeHostFs } from '../../tools/fixtures/fake-exec'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; describe('AgentConversationUndoService', () => { let ctx: TestAgentContext; @@ -269,7 +269,7 @@ describe('AgentConversationUndoService', () => { await undo.undo(1); expect(ctx.agentState.get(planKey).active).toBe(false); - expect(ctx.get(IAgentTelemetryContextService).get().mode).toBe('agent'); + expect(ctx.get(ITelemetryService).getContext().mode).toBe('agent'); expect(restoredModes).toEqual([false]); } finally { subscription.dispose(); @@ -504,7 +504,14 @@ describe('AgentConversationUndoService', () => { expect(records).toContainEqual({ event: 'conversation_undo', - properties: { agent_id: 'main', count: 1 }, + properties: { + agent_id: 'main', + count: 1, + mode: 'agent', + model: 'mock-model', + protocol: 'openai', + provider_type: 'kimi', + }, }); expect(ctx.context.get().map((m) => m.role)).toEqual(['user', 'assistant']); }); @@ -572,7 +579,14 @@ describe('AgentConversationUndoService', () => { expect(undone).toEqual([1]); expect(records).toContainEqual({ event: 'conversation_undo', - properties: { agent_id: 'main', count: 1 }, + properties: { + agent_id: 'main', + count: 1, + mode: 'agent', + model: 'mock-model', + protocol: 'openai', + provider_type: 'kimi', + }, }); } finally { subscription.dispose(); diff --git a/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts b/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts deleted file mode 100644 index c31d5cb88f4..00000000000 --- a/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContextService'; -import { recordingTelemetry, type TelemetryRecord } from './stubs'; - -describe('AgentTelemetryContextService', () => { - it('defaults to agent mode and merges into telemetry through withContext', () => { - const records: TelemetryRecord[] = []; - const telemetry = recordingTelemetry(records); - const ctx = new AgentTelemetryContextService(); - - telemetry.withContext(ctx.get()).track('turn_started'); - expect(records).toContainEqual({ - event: 'turn_started', - properties: { mode: 'agent' }, - }); - - ctx.set({ mode: 'plan' }); - telemetry.withContext(ctx.get()).track('turn_interrupted', { at_step: 2 }); - expect(records).toContainEqual({ - event: 'turn_interrupted', - properties: { mode: 'plan', at_step: 2 }, - }); - }); - - it('snapshots the context at withContext time', () => { - const records: TelemetryRecord[] = []; - const telemetry = recordingTelemetry(records); - const ctx = new AgentTelemetryContextService(); - ctx.set({ mode: 'plan' }); - - const fork = telemetry.withContext(ctx.get()); - ctx.set({ mode: 'agent' }); - - fork.track('turn_interrupted', { at_step: 1 }); - expect(records).toContainEqual({ - event: 'turn_interrupted', - properties: { mode: 'plan', at_step: 1 }, - }); - }); -}); diff --git a/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts b/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts index 89c267e3203..55464e09260 100644 --- a/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts @@ -101,7 +101,7 @@ describe('CloudAppender', () => { }), ); - appender.track('tool.call', { name: 'bash', count: 2 }); + appender.track({ event: 'tool.call', context: {}, properties: { name: 'bash', count: 2 } }); await appender.flush(); expect(requests).toHaveLength(1); @@ -134,7 +134,7 @@ describe('CloudAppender', () => { }), ); - appender.track('tool.call', { name: 'bash' }); + appender.track({ event: 'tool.call', context: {}, properties: { name: 'bash' } }); await appender.flush(); expect(requests).toHaveLength(1); @@ -154,7 +154,7 @@ describe('CloudAppender', () => { }), ); - appender.track('tool.call', { name: 'bash' }); + appender.track({ event: 'tool.call', context: {}, properties: { name: 'bash' } }); await appender.flush(); expect(requests).toHaveLength(1); @@ -175,7 +175,7 @@ describe('CloudAppender', () => { }), ); - appender.track('tool.call', { name: 'bash' }); + appender.track({ event: 'tool.call', context: {}, properties: { name: 'bash' } }); await appender.flush(); expect(requests).toHaveLength(1); @@ -198,7 +198,7 @@ describe('CloudAppender', () => { }), ); - appender.track('tool.call', { name: 'bash' }); + appender.track({ event: 'tool.call', context: {}, properties: { name: 'bash' } }); await appender.flush(); expect(requests).toHaveLength(1); @@ -209,13 +209,13 @@ describe('CloudAppender', () => { } }); - it('applies setContext sessionId and model updates to subsequent events', async () => { + it('uses the ambient session_id for the top-level envelope session_id', async () => { const requests: CapturedRequest[] = []; const appender = new CloudAppender( baseOptions({ homeDir, deviceId: 'dev123', - model: 'initial-model', + sessionId: 'default-session', fetchImpl: makeFetch((req) => { requests.push(req); return okResponse(); @@ -223,16 +223,18 @@ describe('CloudAppender', () => { }), ); - appender.setContext({ sessionId: 'sess42', model: 'switched-model' }); - appender.track('turn_started', {}); + appender.track({ + event: 'turn_started', + context: { session_id: 'ambient-session' }, + properties: { sessionId: 'ambient-session' }, + }); await appender.flush(); - const event = requests[0]?.body.events[0]; - expect(event?.['session_id']).toBe('sess42'); - expect(event?.['context_model']).toBe('switched-model'); + expect(requests[0]?.body.events[0]?.['session_id']).toBe('ambient-session'); + expect(requests[0]?.body.events[0]?.['property_sessionId']).toBe('ambient-session'); }); - it('uses the event sessionId for top-level session_id when it differs from appender context', async () => { + it('falls back to the appender static session id when ambient has none', async () => { const requests: CapturedRequest[] = []; const appender = new CloudAppender( baseOptions({ @@ -245,10 +247,47 @@ describe('CloudAppender', () => { }), ); - appender.track('evt', { sessionId: 'event-session' }); + appender.track({ event: 'turn_started', context: {}, properties: {} }); + await appender.flush(); + + expect(requests[0]?.body.events[0]?.['session_id']).toBe('default-session'); + }); + + it('uses the ambient model for the envelope context when constructed without a model', async () => { + const requests: CapturedRequest[] = []; + const appender = new CloudAppender( + baseOptions({ + homeDir, + fetchImpl: makeFetch((req) => { + requests.push(req); + return okResponse(); + }), + }), + ); + + appender.track({ event: 'turn_started', context: { model: 'ambient-model' }, properties: {} }); + await appender.flush(); + + expect(requests[0]?.body.events[0]?.['context_model']).toBe('ambient-model'); + }); + + it('prefers the ambient model over the constructor model in the envelope context', async () => { + const requests: CapturedRequest[] = []; + const appender = new CloudAppender( + baseOptions({ + homeDir, + model: 'constructor-model', + fetchImpl: makeFetch((req) => { + requests.push(req); + return okResponse(); + }), + }), + ); + + appender.track({ event: 'turn_started', context: { model: 'ambient-model' }, properties: {} }); await appender.flush(); - expect(requests[0]?.body.events[0]?.['session_id']).toBe('event-session'); + expect(requests[0]?.body.events[0]?.['context_model']).toBe('ambient-model'); }); it('sends Authorization header when a token is provided', async () => { @@ -264,7 +303,7 @@ describe('CloudAppender', () => { }), ); - appender.track('evt'); + appender.track({ event: 'evt', context: {}, properties: {} }); await appender.flush(); expect(requests[0]?.headers['Authorization']).toBe('Bearer tok123'); @@ -283,10 +322,10 @@ describe('CloudAppender', () => { }), ); - appender.track('e1'); - appender.track('e2'); + appender.track({ event: 'e1', context: {}, properties: {} }); + appender.track({ event: 'e2', context: {}, properties: {} }); expect(sends).toBe(0); - appender.track('e3'); + appender.track({ event: 'e3', context: {}, properties: {} }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(sends).toBe(1); }); @@ -303,7 +342,7 @@ describe('CloudAppender', () => { }), ); - appender.track('e1'); + appender.track({ event: 'e1', context: {}, properties: {} }); await appender.shutdown(); expect(sends).toBe(1); }); @@ -320,7 +359,7 @@ describe('CloudAppender', () => { }), ); - appender.track('evt'); + appender.track({ event: 'evt', context: {}, properties: {} }); await appender.flush(); expect(attempts).toBe(4); @@ -344,7 +383,7 @@ describe('CloudAppender', () => { }), ); - appender.track('evt'); + appender.track({ event: 'evt', context: {}, properties: {} }); await appender.flush(); expect(seenAuths).toEqual(['Bearer tok', undefined]); @@ -359,7 +398,7 @@ describe('CloudAppender', () => { }), ); - appender.track('evt'); + appender.track({ event: 'evt', context: {}, properties: {} }); await appender.flush(); expect( readdirSync(join(homeDir, 'telemetry')).filter((f) => f.startsWith('failed_')), @@ -387,7 +426,11 @@ describe('CloudAppender', () => { }), ); - appender.track('evt', { ok: 'yes', bad: { nested: true } as unknown as string }); + appender.track({ + event: 'evt', + context: {}, + properties: { ok: 'yes', bad: { nested: true } as unknown as string }, + }); await appender.flush(); const event = requests[0]?.body.events[0]; diff --git a/packages/agent-core-v2/test/app/telemetry/consoleAppender.test.ts b/packages/agent-core-v2/test/app/telemetry/consoleAppender.test.ts index c9012de81f1..f967004ca25 100644 --- a/packages/agent-core-v2/test/app/telemetry/consoleAppender.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/consoleAppender.test.ts @@ -6,7 +6,11 @@ describe('ConsoleAppender', () => { it('logs event name and properties with the default prefix', () => { const lines: string[] = []; const appender = new ConsoleAppender({ log: (message) => lines.push(message) }); - appender.track('tool.call', { name: 'bash', count: 1 }); + appender.track({ + event: 'tool.call', + context: {}, + properties: { name: 'bash', count: 1 }, + }); expect(lines).toHaveLength(1); expect(lines[0]).toContain('[telemetry] tool.call'); expect(lines[0]).toContain('"name":"bash"'); @@ -16,21 +20,21 @@ describe('ConsoleAppender', () => { it('uses a custom prefix', () => { const lines: string[] = []; const appender = new ConsoleAppender({ prefix: '[dbg]', log: (message) => lines.push(message) }); - appender.track('evt'); + appender.track({ event: 'evt', context: {}, properties: {} }); expect(lines[0]).toBe('[dbg] evt'); }); - it('omits the payload when properties is undefined', () => { + it('omits the payload when properties is empty', () => { const lines: string[] = []; const appender = new ConsoleAppender({ log: (message) => lines.push(message) }); - appender.track('evt'); + appender.track({ event: 'evt', context: { session_id: 's1' }, properties: {} }); expect(lines[0]).toBe('[telemetry] evt'); }); it('pretty-prints properties when requested', () => { const lines: string[] = []; const appender = new ConsoleAppender({ pretty: true, log: (message) => lines.push(message) }); - appender.track('evt', { a: 1 }); + appender.track({ event: 'evt', context: {}, properties: { a: 1 } }); expect(lines[0]).toContain('\n'); }); }); diff --git a/packages/agent-core-v2/test/app/telemetry/stubs.ts b/packages/agent-core-v2/test/app/telemetry/stubs.ts index f23e09bacae..85e2dde5530 100644 --- a/packages/agent-core-v2/test/app/telemetry/stubs.ts +++ b/packages/agent-core-v2/test/app/telemetry/stubs.ts @@ -1,9 +1,7 @@ import type { ServiceRegistration } from '#/_base/di/test'; -import { - ITelemetryService, - type TelemetryContextPatch, - type TelemetryProperties, -} from '#/app/telemetry/telemetry'; +import type { TelemetryContextPatch, TelemetryProperties } from '#/app/telemetry/context'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { composeTelemetryProperties } from '#/app/telemetry/telemetryService'; export interface TelemetryRecord { readonly event: string; @@ -18,26 +16,25 @@ export function recordingTelemetry( let enabled = true; const service: ITelemetryService = { _serviceBrand: undefined, - track(event, properties) { + track2: (event, properties) => { if (!enabled) return; records.push({ event, - properties: - properties === undefined - ? currentContext - : { ...currentContext, ...properties }, + properties: composeTelemetryProperties( + currentContext, + properties as TelemetryProperties | undefined, + ), }); }, - track2: (event, properties) => service.track(event, properties as TelemetryProperties), withContext(patch: TelemetryContextPatch) { return recordingTelemetry(records, { ...currentContext, ...patch }); }, setContext(patch: TelemetryContextPatch) { currentContext = { ...currentContext, ...patch }; }, + getContext: () => currentContext, addAppender: () => ({ dispose: () => {} }), removeAppender: () => {}, - setAppender: () => {}, setEnabled(next) { enabled = next; }, diff --git a/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts b/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts index 38572a03728..a8a1ec10cd6 100644 --- a/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts @@ -6,15 +6,26 @@ import { resetUnexpectedErrorHandler, setUnexpectedErrorHandler, } from '#/_base/errors/unexpectedError'; -import { type ITelemetryAppender, type TelemetryProperties, ITelemetryService } from '#/app/telemetry/telemetry'; -import { TelemetryService } from '#/app/telemetry/telemetryService'; +import type { TelemetryProperties } from '#/app/telemetry/context'; +import type { TurnStartedEvent as TurnStartedTelemetryEvent } from '#/app/telemetry/events'; +import { type ITelemetryAppender, ITelemetryService } from '#/app/telemetry/telemetry'; +import { + type ITelemetryScopeBindingHost, + TelemetryService, +} from '#/app/telemetry/telemetryService'; + +interface CapturedRecord { + readonly event: string; + readonly context: TelemetryProperties; + readonly properties: TelemetryProperties; +} class CapturingAppender implements ITelemetryAppender { - readonly events: { event: string; properties?: TelemetryProperties }[] = []; + readonly records: CapturedRecord[] = []; flushCalls = 0; shutdownCalls = 0; - track(event: string, properties?: TelemetryProperties): void { - this.events.push({ event, properties }); + track(record: CapturedRecord): void { + this.records.push(record); } flush(): void { this.flushCalls += 1; @@ -24,13 +35,9 @@ class CapturingAppender implements ITelemetryAppender { } } -function telemetryWithAppenders(...appenders: ITelemetryAppender[]): TelemetryService { +function serviceWithAppenders(...appenders: ITelemetryAppender[]): TelemetryService { const svc = new TelemetryService(); - const [first, ...rest] = appenders; - if (first !== undefined) { - svc.setAppender(first); - } - for (const appender of rest) { + for (const appender of appenders) { svc.addAppender(appender); } return svc; @@ -39,118 +46,116 @@ function telemetryWithAppenders(...appenders: ITelemetryAppender[]): TelemetrySe describe('TelemetryService (unit)', () => { it('noop by default — does not throw', () => { const svc = new TelemetryService(); - expect(() => svc.track('evt', { a: 1 })).not.toThrow(); + expect(() => svc.track2('session_ended', { reason: 'exit' })).not.toThrow(); }); - it('merges bound context into tracked properties', () => { + it('maps ambient session_id to camel sessionId properties', () => { const appender = new CapturingAppender(); - const svc = new TelemetryService(); - svc.setAppender(appender); - svc.setContext({ sessionId: 's1' }); - svc.track('turn.start', { agentId: 'main' }); - expect(appender.events[0]).toEqual({ - event: 'turn.start', - properties: { sessionId: 's1', agentId: 'main' }, + const svc = serviceWithAppenders(appender); + svc.setContext({ session_id: 's1', agent_id: 'a1' }); + svc.track2('session_ended', { reason: 'exit' }); + expect(appender.records[0]).toEqual({ + event: 'session_ended', + context: { session_id: 's1', agent_id: 'a1' }, + properties: { sessionId: 's1', agent_id: 'a1', reason: 'exit' }, }); }); - it('withContext merges context and shares the appender', () => { + it('passes the merged ambient as the appender record context', () => { const appender = new CapturingAppender(); - const root = new TelemetryService(); - root.setAppender(appender); - root.setContext({ sessionId: 's1' }); - const child = root.withContext({ agentId: 'main', turnId: 't1' }); - child.track('tool.call', { name: 'bash' }); - expect(appender.events[0]?.properties).toEqual({ - sessionId: 's1', - agentId: 'main', - turnId: 't1', - name: 'bash', - }); + const svc = serviceWithAppenders(appender); + svc.setContext({ model: 'm1' }); + svc.track2('model_switch', { model: 'm2' }); + expect(appender.records[0]?.context).toEqual({ model: 'm1' }); }); - it('per-call properties override bound context on key collision', () => { + it('per-call properties override ambient context on key collision', () => { const appender = new CapturingAppender(); - const svc = new TelemetryService(); - svc.setAppender(appender); - svc.setContext({ sessionId: 's1' }); - svc.track('evt', { sessionId: 'override' }); - expect(appender.events[0]?.properties?.['sessionId']).toBe('override'); + const svc = serviceWithAppenders(appender); + svc.setContext({ model: 'm1' }); + svc.track2('model_switch', { model: 'override' }); + expect(appender.records[0]?.properties?.['model']).toBe('override'); + }); + + it('drops ambient model from properties when the event declares model but ambient lacks it', () => { + const appender = new CapturingAppender(); + const svc = serviceWithAppenders(appender); + svc.track2('model_switch', { model: 'm2' }); + expect(appender.records[0]?.properties).toEqual({ model: 'm2' }); }); - it('fans out to every appender passed via appenders', () => { + it('fans out to every appender passed via addAppender', () => { const a = new CapturingAppender(); const b = new CapturingAppender(); - const svc = telemetryWithAppenders(a, b); - svc.track('evt', { x: 1 }); - expect(a.events).toEqual([{ event: 'evt', properties: { x: 1 } }]); - expect(b.events).toEqual([{ event: 'evt', properties: { x: 1 } }]); + const svc = serviceWithAppenders(a, b); + svc.track2('session_ended', { reason: 'exit' }); + expect(a.records).toHaveLength(1); + expect(b.records).toHaveLength(1); }); it('addAppender registers an appender and its disposable removes it', () => { const a = new CapturingAppender(); const b = new CapturingAppender(); - const svc = telemetryWithAppenders(a); + const svc = serviceWithAppenders(a); const disposable = svc.addAppender(b); - svc.track('first'); - expect(a.events).toHaveLength(1); - expect(b.events).toHaveLength(1); + svc.track2('session_ended', { reason: 'exit' }); + expect(a.records).toHaveLength(1); + expect(b.records).toHaveLength(1); disposable.dispose(); - svc.track('second'); - expect(a.events).toHaveLength(2); - expect(b.events).toHaveLength(1); + svc.track2('session_ended', { reason: 'archive' }); + expect(a.records).toHaveLength(2); + expect(b.records).toHaveLength(1); }); it('removeAppender stops delivery to that appender', () => { const a = new CapturingAppender(); const b = new CapturingAppender(); - const svc = telemetryWithAppenders(a, b); + const svc = serviceWithAppenders(a, b); svc.removeAppender(a); - svc.track('evt'); - expect(a.events).toHaveLength(0); - expect(b.events).toHaveLength(1); + svc.track2('session_ended', { reason: 'exit' }); + expect(a.records).toHaveLength(0); + expect(b.records).toHaveLength(1); }); - it('setEnabled(false) drops track; setEnabled(true) resumes', () => { + it('setEnabled(false) drops track2; setEnabled(true) resumes', () => { const appender = new CapturingAppender(); - const svc = telemetryWithAppenders(appender); + const svc = serviceWithAppenders(appender); svc.setEnabled(false); - svc.track('dropped'); - expect(appender.events).toHaveLength(0); + svc.track2('session_ended', { reason: 'exit' }); + expect(appender.records).toHaveLength(0); svc.setEnabled(true); - svc.track('sent'); - expect(appender.events).toEqual([{ event: 'sent', properties: {} }]); + svc.track2('session_ended', { reason: 'exit' }); + expect(appender.records).toHaveLength(1); }); - it('withContext view follows root enablement changes', () => { + it('setContext with undefined removes the key from the layer', () => { const appender = new CapturingAppender(); - const root = telemetryWithAppenders(appender); - const child = root.withContext({ turnId: 't1' }); - - root.setEnabled(false); - child.track('dropped'); - expect(appender.events).toHaveLength(0); - - root.setEnabled(true); - child.track('sent'); - expect(appender.events).toEqual([{ event: 'sent', properties: { turnId: 't1' } }]); + const svc = serviceWithAppenders(appender); + svc.setContext({ model: 'm1' }); + svc.setContext({ model: undefined }); + expect(svc.getContext()).toEqual({}); + svc.track2('session_ended', { reason: 'exit' }); + expect(appender.records[0]?.properties).toEqual({ reason: 'exit' }); }); - it('withContext view follows root appender changes', () => { - const root = new TelemetryService(); - const child = root.withContext({ agent_id: 'main' }); + it('withContext view follows root enablement changes', () => { const appender = new CapturingAppender(); + const svc = serviceWithAppenders(appender); + const child = svc.withContext({ session_id: 's1' }); - root.setAppender(appender); - child.track('sent'); + svc.setEnabled(false); + child.track2('session_ended', { reason: 'exit' }); + expect(appender.records).toHaveLength(0); - expect(appender.events).toEqual([{ event: 'sent', properties: { agent_id: 'main' } }]); + svc.setEnabled(true); + child.track2('session_ended', { reason: 'exit' }); + expect(appender.records).toHaveLength(1); }); it('flush fans out to every appender', async () => { const a = new CapturingAppender(); const b = new CapturingAppender(); - const svc = telemetryWithAppenders(a, b); + const svc = serviceWithAppenders(a, b); await svc.flush(); expect(a.flushCalls).toBe(1); expect(b.flushCalls).toBe(1); @@ -159,7 +164,7 @@ describe('TelemetryService (unit)', () => { it('shutdown fans out to every appender', async () => { const a = new CapturingAppender(); const b = new CapturingAppender(); - const svc = telemetryWithAppenders(a, b); + const svc = serviceWithAppenders(a, b); await svc.shutdown(); expect(a.shutdownCalls).toBe(1); expect(b.shutdownCalls).toBe(1); @@ -167,7 +172,7 @@ describe('TelemetryService (unit)', () => { it('flush is a no-op for appenders without flush', async () => { const minimal: ITelemetryAppender = { track() {} }; - const svc = telemetryWithAppenders(minimal); + const svc = serviceWithAppenders(minimal); await expect(svc.flush()).resolves.toBeUndefined(); await expect(svc.shutdown()).resolves.toBeUndefined(); }); @@ -184,9 +189,9 @@ describe('TelemetryService (error isolation)', () => { }, }; const good = new CapturingAppender(); - const svc = telemetryWithAppenders(bad, good); - expect(() => svc.track('evt')).not.toThrow(); - expect(good.events).toEqual([{ event: 'evt', properties: {} }]); + const svc = serviceWithAppenders(bad, good); + expect(() => svc.track2('session_ended', { reason: 'exit' })).not.toThrow(); + expect(good.records).toHaveLength(1); }); it('flush tolerates a rejecting appender and still flushes the rest', async () => { @@ -197,7 +202,7 @@ describe('TelemetryService (error isolation)', () => { }, }; const good = new CapturingAppender(); - const svc = telemetryWithAppenders(bad, good); + const svc = serviceWithAppenders(bad, good); await expect(svc.flush()).resolves.toBeUndefined(); expect(good.flushCalls).toBe(1); }); @@ -210,12 +215,313 @@ describe('TelemetryService (error isolation)', () => { }, }; const good = new CapturingAppender(); - const svc = telemetryWithAppenders(bad, good); + const svc = serviceWithAppenders(bad, good); await expect(svc.shutdown()).resolves.toBeUndefined(); expect(good.shutdownCalls).toBe(1); }); }); +describe('TelemetryService (layered ambient)', () => { + it('merges App → Session → Agent fragments with the nearest layer winning', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + root.setContext({ session_id: 'app-level', model: 'm1' }); + + const session = root.createScopeBinding({ session_id: 's1' }); + const agent = (session.telemetry as ITelemetryService & ITelemetryScopeBindingHost) + .createScopeBinding({ agent_id: 'a1', mode: 'agent' }); + + agent.telemetry.track2('session_ended', { reason: 'exit' }); + expect(appender.records[0]?.properties).toEqual({ + sessionId: 's1', + agent_id: 'a1', + mode: 'agent', + model: 'm1', + reason: 'exit', + }); + expect(appender.records[0]?.context).toEqual({ + session_id: 's1', + agent_id: 'a1', + mode: 'agent', + model: 'm1', + }); + }); + + it('setContext on a bound handle writes its own fragment', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const session = root.createScopeBinding({ session_id: 's1' }); + session.telemetry.setContext({ model: 'session-model' }); + expect(root.getContext()).toEqual({}); + expect(session.telemetry.getContext()).toEqual({ + session_id: 's1', + model: 'session-model', + }); + }); + + it('a turn event picks the ambient turn fragment up', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const session = root.createScopeBinding({ session_id: 's1' }); + const agent = (session.telemetry as ITelemetryService & ITelemetryScopeBindingHost) + .createScopeBinding({ agent_id: 'a1', mode: 'agent' }); + agent.telemetry.setContext({ turn_id: 3 }); + agent.telemetry.track2('tool_call_dedup_detected', { + step_no: 1, + tool_call_id: 'call_1', + tool_name: 'bash', + dup_type: 'same_step', + args_hash: 'hash-1', + }); + expect(appender.records[0]?.properties).toEqual({ + sessionId: 's1', + agent_id: 'a1', + mode: 'agent', + turn_id: 3, + step_no: 1, + tool_call_id: 'call_1', + tool_name: 'bash', + dup_type: 'same_step', + args_hash: 'hash-1', + }); + }); + + it('an event not declaring context fields still receives the full ambient context', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const agent = root.createScopeBinding({ + agent_id: 'a1', + mode: 'plan', + }); + agent.telemetry.setContext({ + turn_id: 3, + trace_id: 'trace-1', + thinking_effort: 'high', + provider_type: 'kimi', + protocol: 'openai', + }); + agent.telemetry.track2('skill_invoked', { + skill_name: 'review', + trigger: 'user-slash', + }); + expect(appender.records[0]?.properties).toEqual({ + agent_id: 'a1', + mode: 'plan', + turn_id: 3, + trace_id: 'trace-1', + thinking_effort: 'high', + provider_type: 'kimi', + protocol: 'openai', + skill_name: 'review', + trigger: 'user-slash', + }); + }); + + it('explicitly passed fields pass through even when the event does not declare them', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const agent = root.createScopeBinding({ + agent_id: 'a1', + mode: 'agent', + }); + agent.telemetry.track2('skill_invoked', { + skill_name: 'review', + trigger: 'user-slash', + turn_id: 3, + trace_id: 'trace-1', + } as never); + expect(appender.records[0]?.properties).toEqual({ + agent_id: 'a1', + mode: 'agent', + turn_id: 3, + trace_id: 'trace-1', + skill_name: 'review', + trigger: 'user-slash', + }); + }); + + it('events emitted after a turn ends carry no turn_id', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const agent = root.createScopeBinding({ + agent_id: 'a1', + mode: 'agent', + }); + agent.telemetry.setContext({ turn_id: 3 }); + agent.telemetry.track2('tool_call_dedup_detected', { + step_no: 1, + tool_call_id: 'call_1', + tool_name: 'bash', + dup_type: 'same_step', + args_hash: 'hash-1', + }); + expect(appender.records[0]?.properties?.['turn_id']).toBe(3); + + agent.telemetry.setContext({ turn_id: undefined }); + agent.telemetry.track2('tool_call_dedup_detected', { + step_no: 2, + tool_call_id: 'call_2', + tool_name: 'bash', + dup_type: 'same_step', + args_hash: 'hash-2', + }); + expect(appender.records[1]?.properties?.['turn_id']).toBeUndefined(); + expect(appender.records[1]?.properties).toEqual({ + agent_id: 'a1', + mode: 'agent', + step_no: 2, + tool_call_id: 'call_2', + tool_name: 'bash', + dup_type: 'same_step', + args_hash: 'hash-2', + }); + }); + + it('profile and plan writes flow into subsequent turn events', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const agent = root.createScopeBinding({ + agent_id: 'a1', + mode: 'agent', + }); + agent.telemetry.setContext({ provider_type: 'kimi', protocol: 'openai' }); + agent.telemetry.setContext({ mode: 'plan' }); + agent.telemetry.setContext({ turn_id: 1 }); + const { mode, provider_type, protocol } = agent.telemetry.getContext(); + const started: TurnStartedTelemetryEvent = { + turn_id: 1, + mode: mode ?? 'agent', + provider_type, + protocol, + thinking_effort: 'off', + }; + agent.telemetry.track2('turn_started', started); + expect(appender.records[0]?.properties).toEqual({ + agent_id: 'a1', + turn_id: 1, + mode: 'plan', + provider_type: 'kimi', + protocol: 'openai', + thinking_effort: 'off', + }); + }); + + it('withContext snapshots isolate the view from later setContext writes', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const session = root.createScopeBinding({ session_id: 's1' }); + session.telemetry.setContext({ model: 'm1' }); + + const snapshot = session.telemetry.withContext({ session_id: 's2' }); + session.telemetry.setContext({ model: 'm2' }); + root.setContext({ model: 'root-model' }); + + snapshot.track2('session_ended', { reason: 'exit' }); + expect(appender.records[0]?.properties).toEqual({ + sessionId: 's2', + model: 'm1', + reason: 'exit', + }); + + session.telemetry.track2('session_ended', { reason: 'exit' }); + expect(appender.records[1]?.properties).toEqual({ + sessionId: 's1', + model: 'm2', + reason: 'exit', + }); + }); + + it('disposing a scope binding removes its fragment and degrades to the parent chain', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const session = root.createScopeBinding({ session_id: 's1' }); + const agent = (session.telemetry as ITelemetryService & ITelemetryScopeBindingHost) + .createScopeBinding({ agent_id: 'a1' }); + + agent.dispose(); + agent.telemetry.track2('session_ended', { reason: 'exit' }); + expect(appender.records[0]?.properties).toEqual({ + sessionId: 's1', + reason: 'exit', + }); + }); + + it('events emitted through a disposed session binding fall back to the App layer', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + root.setContext({ model: 'app-model' }); + const session = root.createScopeBinding({ session_id: 's1' }); + session.dispose(); + session.telemetry.track2('session_ended', { reason: 'exit' }); + expect(appender.records[0]?.properties).toEqual({ + model: 'app-model', + reason: 'exit', + }); + }); + + it('disposing one binding leaves a sibling binding untouched', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const first = root.createScopeBinding({ session_id: 's1' }); + const second = root.createScopeBinding({ + session_id: 's1', + model: 'resumed-model', + }); + first.dispose(); + second.telemetry.track2('session_started', { resumed: true, experimental_flags: '' }); + expect(appender.records[0]?.properties).toEqual({ + sessionId: 's1', + model: 'resumed-model', + resumed: true, + experimental_flags: '', + }); + second.dispose(); + second.telemetry.track2('session_started', { resumed: true, experimental_flags: '' }); + expect(appender.records[1]?.properties).toEqual({ resumed: true, experimental_flags: '' }); + }); + + it('context writes on one binding do not leak into a sibling binding', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const first = root.createScopeBinding({ agent_id: 'a1', mode: 'agent' }); + const second = root.createScopeBinding({ agent_id: 'a1', mode: 'plan' }); + first.telemetry.setContext({ turn_id: 7, mode: 'agent' }); + first.telemetry.setContext({ turn_id: undefined, mode: 'agent' }); + second.telemetry.track2('turn_started', { turn_id: 3, mode: 'plan' }); + expect(appender.records[0]?.properties).toEqual({ + agent_id: 'a1', + turn_id: 3, + mode: 'plan', + }); + }); + + it('each binding emits with its own fragment', () => { + const appender = new CapturingAppender(); + const root = serviceWithAppenders(appender); + const first = root.createScopeBinding({ agent_id: 'a1', mode: 'agent' }); + first.telemetry.setContext({ provider_type: 'old-provider' }); + root.createScopeBinding({ + agent_id: 'a1', + mode: 'plan', + provider_type: 'new-provider', + }); + first.telemetry.track2('turn_ended', { + turn_id: 7, + reason: 'completed', + duration_ms: 1, + mode: 'agent', + }); + expect(appender.records[0]?.properties).toEqual({ + agent_id: 'a1', + turn_id: 7, + reason: 'completed', + duration_ms: 1, + mode: 'agent', + provider_type: 'old-provider', + }); + }); +}); + describe('ITelemetryService (scoped)', () => { beforeEach(() => { _clearScopedRegistryForTests(); @@ -231,7 +537,7 @@ describe('ITelemetryService (scoped)', () => { it('resolves from the App scope', () => { const host = createScopedTestHost(); const svc = host.app.accessor.get(ITelemetryService); - expect(() => svc.track('scoped')).not.toThrow(); + expect(() => svc.track2('session_ended', { reason: 'exit' })).not.toThrow(); host.dispose(); }); }); diff --git a/packages/agent-core-v2/test/features/goal/goal.test.ts b/packages/agent-core-v2/test/features/goal/goal.test.ts index e950ab8678d..d5c0a138a4a 100644 --- a/packages/agent-core-v2/test/features/goal/goal.test.ts +++ b/packages/agent-core-v2/test/features/goal/goal.test.ts @@ -617,7 +617,15 @@ describe('AgentGoalService', () => { 'goal_status_changed', 'goal_cleared', ]); - expect(telemetry[0]?.properties).toEqual({ agent_id: 'main', actor: 'user', replace: true }); + expect(telemetry[0]?.properties).toEqual({ + agent_id: 'main', + actor: 'user', + replace: true, + mode: 'agent', + model: 'mock-model', + protocol: 'openai', + provider_type: 'kimi', + }); expect(telemetry[1]?.properties).toMatchObject({ actor: 'model', has_token_budget: true }); expect(telemetry[3]?.properties).toMatchObject({ status: 'paused', actor: 'user' }); expect(JSON.stringify(telemetry)).not.toContain('private objective'); diff --git a/packages/agent-core-v2/test/features/goal/goalOps.test.ts b/packages/agent-core-v2/test/features/goal/goalOps.test.ts index ee7f9d8a1f4..ceeea88e8c7 100644 --- a/packages/agent-core-v2/test/features/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/features/goal/goalOps.test.ts @@ -64,7 +64,6 @@ function createContextStub(): IAgentContextMemoryService { function createTelemetryStub(): ITelemetryService { return { _serviceBrand: undefined, - track: () => undefined, track2: () => undefined, } as unknown as ITelemetryService; } diff --git a/packages/agent-core-v2/test/features/plan/planGuard.test.ts b/packages/agent-core-v2/test/features/plan/planGuard.test.ts index 52cc77ff3c8..a97da7846d5 100644 --- a/packages/agent-core-v2/test/features/plan/planGuard.test.ts +++ b/packages/agent-core-v2/test/features/plan/planGuard.test.ts @@ -22,7 +22,6 @@ import type { BeforeExecuteDecision, ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { ToolCall } from '#/kosong/contract/message'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -176,7 +175,6 @@ describe('AgentPlanService plan-guard listener', () => { }); reg.definePartialInstance(IAgentContextMemoryService, {}); reg.defineInstance(IAgentReminderService, createReminderStub()); - reg.definePartialInstance(IAgentTelemetryContextService, { set: () => {} }); reg.defineInstance(IAgentToolExecutorService, executorEvents.executor); reg.defineInstance(IAgentToolApprovalService, toolApproval); reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); @@ -358,7 +356,7 @@ describe('AgentPlanService plan-guard listener', () => { expect(requests[0]?.ask.reason).toEqual({ has_options: false }); expect(records).toContainEqual({ event: 'plan_submitted', - properties: { has_options: false }, + properties: { has_options: false, mode: 'plan' }, }); expect(decision?.veto).toBeDefined(); }); @@ -380,11 +378,11 @@ describe('AgentPlanService plan-guard listener', () => { expect(decision?.veto?.output).toContain('## Approved Plan:\n# Plan'); expect(records).toContainEqual({ event: 'plan_submitted', - properties: { has_options: true }, + properties: { has_options: true, mode: 'plan' }, }); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'approved', chosen_option: 'Approach B' }, + properties: { outcome: 'approved', chosen_option: 'Approach B', mode: 'agent' }, }); expect(await svc.status()).toBeNull(); }); @@ -401,7 +399,7 @@ describe('AgentPlanService plan-guard listener', () => { expect(decision?.veto?.output).not.toContain('Selected approach:'); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'approved' }, + properties: { outcome: 'approved', mode: 'agent' }, }); expect(await svc.status()).toBeNull(); }); @@ -432,7 +430,7 @@ describe('AgentPlanService plan-guard listener', () => { }); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'rejected_and_exited' }, + properties: { outcome: 'rejected_and_exited', mode: 'plan' }, }); expect(await svc.status()).toBeNull(); }); @@ -452,7 +450,7 @@ describe('AgentPlanService plan-guard listener', () => { expect(decision?.veto?.output).toContain('Add verification.'); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'revise', has_feedback: true }, + properties: { outcome: 'revise', has_feedback: true, mode: 'plan' }, }); expect(await svc.status()).not.toBeNull(); }); @@ -471,7 +469,7 @@ describe('AgentPlanService plan-guard listener', () => { }); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'rejected' }, + properties: { outcome: 'rejected', mode: 'plan' }, }); expect(await svc.status()).not.toBeNull(); }); @@ -489,7 +487,7 @@ describe('AgentPlanService plan-guard listener', () => { }); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'dismissed' }, + properties: { outcome: 'dismissed', mode: 'plan' }, }); expect(await svc.status()).not.toBeNull(); }); diff --git a/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts b/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts index b80f0a30df6..810b5aad810 100644 --- a/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts +++ b/packages/agent-core-v2/test/features/plan/tools/exit-plan-mode.test.ts @@ -40,13 +40,12 @@ function planService(): IAgentPlanService { function recordingTelemetry(): ITelemetryService { return { _serviceBrand: undefined, - track: vi.fn(), track2: vi.fn(), withContext: () => recordingTelemetry(), setContext: () => {}, + getContext: () => ({}), addAppender: () => ({ dispose: () => {} }), removeAppender: () => {}, - setAppender: () => {}, setEnabled: () => {}, flush: () => Promise.resolve(), shutdown: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts b/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts index 7d7989ab9d0..15ba029888f 100644 --- a/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts +++ b/packages/agent-core-v2/test/features/plan/tools/plan-tools-telemetry.test.ts @@ -44,13 +44,12 @@ function recordingTelemetry(): { return { telemetry: { _serviceBrand: undefined, - track: vi.fn(), track2, withContext: () => recordingTelemetry().telemetry, setContext: () => {}, + getContext: () => ({}), addAppender: () => ({ dispose: () => {} }), removeAppender: () => {}, - setAppender: () => {}, setEnabled: () => {}, flush: () => Promise.resolve(), shutdown: () => Promise.resolve(), @@ -254,7 +253,14 @@ describe('AgentPlanService EnterPlanMode telemetry', () => { ).toBe(false); expect(records).toContainEqual({ event: 'plan_enter_resolved', - properties: { agent_id: 'main', outcome: 'auto_approved' }, + properties: { + agent_id: 'main', + mode: 'plan', + model: 'mock-model', + outcome: 'auto_approved', + protocol: 'openai', + provider_type: 'kimi', + }, }); }); }); diff --git a/packages/agent-core-v2/test/features/tower/store.test.ts b/packages/agent-core-v2/test/features/tower/store.test.ts index 9b87d96e217..3f88083b766 100644 --- a/packages/agent-core-v2/test/features/tower/store.test.ts +++ b/packages/agent-core-v2/test/features/tower/store.test.ts @@ -90,6 +90,40 @@ async function cleanReview(reviewer: string, target: string): Promise { }); } +describe('init in a non-git directory', () => { + it('bootstraps an empty directory with git init and an empty initial commit', async () => { + const dir = await mkdtemp(join(tmpdir(), 'tower-store-nogit-empty-')); + try { + const result = await new TowerStore(dir).init('session-a'); + const branch = await git(dir, 'symbolic-ref', '--short', 'HEAD'); + expect(result).toMatchObject({ base: branch, created: true, checkout: branch }); + expect(await git(dir, 'rev-list', '--count', 'HEAD')).toBe('1'); + expect(await git(dir, 'log', '-1', '--format=%s')).toBe('tower: init'); + expect(await git(dir, 'status', '--porcelain')).toBe(''); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('snapshots the existing files of a non-empty directory onto the requested base', async () => { + const dir = await mkdtemp(join(tmpdir(), 'tower-store-nogit-dirty-')); + try { + await mkdir(join(dir, 'src'), { recursive: true }); + await writeFile(join(dir, 'src', 'app.ts'), 'export {}\n'); + await writeFile(join(dir, 'README.md'), '# scratch\n'); + const result = await new TowerStore(dir).init('session-a', 'tower-base'); + expect(result).toMatchObject({ base: 'tower-base', created: true, checkout: 'tower-base' }); + expect(await git(dir, 'log', '-1', '--format=%s')).toBe( + 'tower: snapshot of uncommitted base checkout changes (base tower-base)', + ); + expect(await git(dir, 'ls-files')).toContain('src/app.ts'); + expect(await git(dir, 'status', '--porcelain')).toBe(''); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + describe('init', () => { it('creates the directory skeleton, state.json, and the git exclude entry', async () => { const result = await store.init(); @@ -243,6 +277,58 @@ describe('init', () => { }); }); +describe('release', () => { + it('clears the recorded owner and logs the release when the session matches', async () => { + await store.init('session-a'); + + await store.release('session-a'); + + const state = await store.load(); + expect(state.sessionId).toBeUndefined(); + const log = await store.recentLog(5); + expect(log.some((line) => line.includes(' release ') && line.includes('session=session-a'))).toBe(true); + }); + + it('keeps the recorded owner for a different session and logs nothing', async () => { + await store.init('session-a'); + + await store.release('session-b'); + + const state = await store.load(); + expect(state.sessionId).toBe('session-a'); + const log = await store.recentLog(5); + expect(log.some((line) => line.includes(' release '))).toBe(false); + }); + + it('is a no-op while the workspace is not initialized', async () => { + await store.release('session-a'); + + expect(await store.isInitialized()).toBe(false); + }); + + it('is idempotent once ownership is already released', async () => { + await store.init('session-a'); + await store.release('session-a'); + + await store.release('session-a'); + + expect((await store.load()).sessionId).toBeUndefined(); + const log = await store.recentLog(10); + expect(log.filter((line) => line.includes(' release '))).toHaveLength(1); + }); + + it('lets another session adopt the workspace after the owner released it', async () => { + await store.init('session-a'); + await store.release('session-a'); + + const result = await store.init('session-b'); + + expect(result.created).toBe(false); + expect(result.retiredAgents).toEqual([]); + expect((await store.load()).sessionId).toBe('session-b'); + }); +}); + describe('markAgentDied', () => { it('marks the roster entry and appends an activity log line', async () => { await store.init('session-a'); diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index f4cdf4958f8..c6c56dd2dbd 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -132,7 +132,7 @@ describe('TowerSpawnTool', () => { get requestedBase() { return undefined; }, - enter: () => {}, + enter: () => Promise.resolve({ entered: true as const }), exit: () => {}, } as unknown as IAgentTowerService); ix.stub(ITowerRateLimitService, { diff --git a/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts b/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts index 196fe944e67..e8a66627c86 100644 --- a/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts @@ -130,6 +130,7 @@ beforeEach(async () => { }, enter: () => { towerActive = true; + return Promise.resolve({ entered: true as const }); }, exit: () => { towerActive = false; @@ -298,6 +299,24 @@ describe('TowerInitTool', () => { const state = await new TowerStore(repo).load(); expect(state.sessionId).toBe('session-test'); }); + + it('adopts once the owning session released ownership, even while it is still live', async () => { + await initViaTool(); + liveSessionIds = ['session-test']; + currentSessionId = 'session-next'; + + const blocked = await run(ix.get(ITowerInitTool), {}); + expect(blocked.isError).toBe(true); + expect(blocked.output).toContain('owned by a live session (session-test)'); + + await new TowerStore(repo).release('session-test'); + + const adopted = await run(ix.get(ITowerInitTool), {}); + expect(adopted.isError).toBeFalsy(); + expect(adopted.output).toContain('tower workspace already initialized'); + const state = await new TowerStore(repo).load(); + expect(state.sessionId).toBe('session-next'); + }); }); describe('TowerPlanTool', () => { @@ -350,6 +369,22 @@ describe('TowerTeardownTool', () => { expect(result.output).toContain('dismantle that session'); expect((await new TowerStore(repo).load()).sessionId).toBe('session-test'); }); + + it('tears down once the owning session released ownership, even while it is still live', async () => { + await initViaTool(); + liveSessionIds = ['session-test']; + currentSessionId = 'session-next'; + + const blocked = await run(ix.get(ITowerTeardownTool), {}); + expect(blocked.isError).toBe(true); + expect(blocked.output).toContain('dismantle that session'); + + await new TowerStore(repo).release('session-test'); + + const result = await run(ix.get(ITowerTeardownTool), {}); + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('tower teardown:'); + }); }); describe('TowerSendTool + TowerInboxTool', () => { diff --git a/packages/agent-core-v2/test/features/tower/towerFeature.test.ts b/packages/agent-core-v2/test/features/tower/towerFeature.test.ts index 8d540cbc854..c6032ea8de5 100644 --- a/packages/agent-core-v2/test/features/tower/towerFeature.test.ts +++ b/packages/agent-core-v2/test/features/tower/towerFeature.test.ts @@ -1,3 +1,7 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { stringify as stringifyToml } from 'smol-toml'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type CollectionToken, type CollectionView } from '#/_base/di/collection'; @@ -132,6 +136,89 @@ describe('TowerFeature — experimental flag gating', () => { }); }); +describe('TowerFeature — config-sourced flag assembly', () => { + let disposables: DisposableStore; + let homeDir: string; + + beforeEach(() => { + disposables = new DisposableStore(); + homeDir = `/tmp/kimi-code-tower-assembly-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + _clearScopedRegistryForTests(); + _clearFeatureRecipesForTests(); + registerScopedService( + LifecycleScope.App, + IFeatureManager, + FeatureManagerService, + ScopeActivation.OnScopeCreated, + 'feature', + ); + registerScopedService( + LifecycleScope.App, + IFeatureAssemblyService, + FeatureAssemblyService, + ScopeActivation.OnScopeCreated, + 'features', + ); + registerFeature(TowerFeature); + }); + afterEach(() => disposables.dispose()); + + async function makeRealFlags(preseed?: Record) { + const ix = disposables.add(new TestInstantiationService()); + ix.stub(IBootstrapService, stubBootstrap(homeDir)); + ix.stub(ILogService, stubLog()); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + ix.set(IFlagRegistry, new SyncDescriptor(FlagRegistryService)); + ix.set(IFlagService, new SyncDescriptor(FlagService)); + if (preseed !== undefined) { + mkdirSync(homeDir, { recursive: true }); + writeFileSync(join(homeDir, 'config.toml'), `${stringifyToml(preseed)}\n`); + await ix.get(IAtomicTomlDocumentStore).set('', 'config.toml', preseed); + } + return { config: ix.get(IConfigService), flags: ix.get(IFlagService) }; + } + + it('assembles a config-sourced flag at startup', async () => { + const { flags } = await makeRealFlags({ experimental: { [TOWER_FLAG_ID]: true } }); + const host = createScopedTestHost([[IFlagService, flags]]); + + expect(isTowerFeatureAssembled(flags)).toBe(true); + expect(flags.explain(TOWER_FLAG_ID)).toMatchObject({ enabled: true, source: 'config' }); + const manager = host.app.accessor.get(IFeatureManager); + expect( + manager + .contributedServices() + .filter( + (entry) => entry.scope === LifecycleScope.App && entry.id === ITowerRateLimitService, + ), + ).toHaveLength(1); + const agent = host.child(LifecycleScope.Agent, 'agent-1'); + expect(collectionViewOf(agent, AgentToolContribution).items).toHaveLength(11); + host.dispose(); + }); + + it('does not assemble on a config flip after startup — a restart is required', async () => { + const { config, flags } = await makeRealFlags(); + const host = createScopedTestHost([[IFlagService, flags]]); + const manager = host.app.accessor.get(IFeatureManager); + expect(manager.units().map((unit) => unit.name)).toEqual(['tower']); + expect(isTowerFeatureAssembled(flags)).toBe(false); + + await config.set(EXPERIMENTAL_SECTION, { [TOWER_FLAG_ID]: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(flags.enabled(TOWER_FLAG_ID)).toBe(true); + expect(isTowerFeatureAssembled(flags)).toBe(false); + expect(manager.contributedServices()).toHaveLength(0); + const agent = host.child(LifecycleScope.Agent, 'agent-1'); + expect(collectionViewOf(agent, AgentToolContribution).items).toHaveLength(0); + host.dispose(); + }); +}); + describe('tower flag — resolution', () => { let disposables: DisposableStore; let homeDir: string; diff --git a/packages/agent-core-v2/test/features/tower/towerService.test.ts b/packages/agent-core-v2/test/features/tower/towerService.test.ts index b65efea3dea..35b15446c18 100644 --- a/packages/agent-core-v2/test/features/tower/towerService.test.ts +++ b/packages/agent-core-v2/test/features/tower/towerService.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentReminderService } from '#/features/reminder/reminderService'; import { createReminderStub } from '../reminder/stubs'; @@ -25,7 +26,12 @@ import type { ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; import { TowerStore } from '#/features/tower/protocol/index'; -import { IAgentTowerService, TOWER_FLAG_ID } from '#/features/tower/tower'; +import { + IAgentTowerService, + TOWER_FLAG_ID, + towerEnterFailureMessage, + type TowerEnterFailure, +} from '#/features/tower/tower'; import { _setTowerFeatureAssembledForTests } from '#/features/tower/towerFeature'; import { AgentTowerService, TOWER_MODE_TOOLS } from '#/features/tower/towerService'; import { towerKey } from '#/features/tower/towerOps'; @@ -44,6 +50,7 @@ import { ISessionActivityView, type SessionPendingInteraction, } from '#/session/sessionActivity/sessionActivity'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import type { ToolCall } from '#/kosong/contract/message'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; @@ -55,6 +62,7 @@ import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../../agent/toolExecutor/stubs'; import { stubFlag } from '../../app/flag/stubs'; +import { stubLog } from '../../_base/log/stubs'; import { appService, createTestAgent, @@ -138,7 +146,7 @@ describe('AgentTowerService', () => { let addedTools: string[]; let removedTools: string[]; let activeTools: string[] | undefined; - let liveSessions: Map void> }>; + let liveSessions: Map void>; title?: string; metadataReadFails?: boolean }>; let fireUnitsChanged: () => void = () => {}; beforeEach(() => { @@ -183,6 +191,14 @@ describe('AgentTowerService', () => { }), }; } + if (token === (ISessionMetadata as unknown)) { + return { + read: async () => { + if (stub.metadataReadFails === true) throw new Error('metadata read failed'); + return { title: stub.title }; + }, + }; + } return undefined; }, }, @@ -214,6 +230,7 @@ describe('AgentTowerService', () => { IAgentReminderService, createReminderStub(), ); + ix.stub(ILogService, stubLog()); ix.stub(IAgentContextMemoryService, { get: () => [], } as unknown as IAgentContextMemoryService); @@ -251,7 +268,7 @@ describe('AgentTowerService', () => { ); expect(tower.isActive).toBe(false); - await tower.enter(); + await expect(tower.enter()).resolves.toEqual({ entered: true }); expect(tower.isActive).toBe(true); tower.exit(); expect(tower.isActive).toBe(false); @@ -856,7 +873,7 @@ describe('AgentTowerService', () => { expect(formatDenyMessage).toHaveBeenCalledTimes(1); }); - it('enter() is a no-op while the tower flag is off', async () => { + it('enter() reports experiment-off while the tower flag is off', async () => { towerFlagOn = false; const tower = ix.get(IAgentTowerService); const events: { readonly type: string }[] = []; @@ -866,18 +883,18 @@ describe('AgentTowerService', () => { }), ); - await tower.enter(); + await expect(tower.enter()).resolves.toEqual({ entered: false, reason: 'experiment-off' }); expect(tower.isActive).toBe(false); expect(events).toEqual([]); }); - it('enter() is a no-op until the feature is assembled — a live flag flip needs a restart', async () => { + it('enter() reports feature-not-assembled until the feature is assembled — a live flag flip needs a restart', async () => { _setTowerFeatureAssembledForTests(false); try { const tower = ix.get(IAgentTowerService); - await tower.enter(); + await expect(tower.enter()).resolves.toEqual({ entered: false, reason: 'feature-not-assembled' }); expect(tower.isActive).toBe(false); expect(addedTools).toEqual([]); @@ -945,18 +962,20 @@ describe('AgentTowerService', () => { function stubLiveSession( id: string, - init: { busy?: boolean; pendingInteraction?: SessionPendingInteraction } = {}, + init: { busy?: boolean; pendingInteraction?: SessionPendingInteraction; title?: string; metadataReadFails?: boolean } = {}, ): Mock<() => void> { const exit = vi.fn(); liveSessions.set(id, { busy: init.busy ?? false, pendingInteraction: init.pendingInteraction ?? 'none', exit, + title: init.title, + metadataReadFails: init.metadataReadFails, }); return exit; } - it('enter() is a no-op while a busy foreign session owns the tower in this process', async () => { + it('enter() reports owned-by-live-session with the owner id while a busy foreign session owns the tower in this process', async () => { const repo = await mkdtemp(join(tmpdir(), 'tower-enter-foreign-')); try { await initGitRepo(repo); @@ -969,7 +988,11 @@ describe('AgentTowerService', () => { ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); const tower = ix.get(IAgentTowerService); - await tower.enter(); + await expect(tower.enter()).resolves.toEqual({ + entered: false, + reason: 'owned-by-live-session', + owner: 'session-original', + }); expect(tower.isActive).toBe(false); expect(addedTools).toEqual([]); @@ -978,7 +1001,7 @@ describe('AgentTowerService', () => { } }); - it('enter() is a no-op while the owning session waits on an interaction', async () => { + it('enter() reports owned-by-live-session while the owning session waits on an interaction', async () => { const repo = await mkdtemp(join(tmpdir(), 'tower-enter-pending-')); try { await initGitRepo(repo); @@ -991,7 +1014,11 @@ describe('AgentTowerService', () => { ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); const tower = ix.get(IAgentTowerService); - await tower.enter(); + await expect(tower.enter()).resolves.toEqual({ + entered: false, + reason: 'owned-by-live-session', + owner: 'session-original', + }); expect(tower.isActive).toBe(false); expect(addedTools).toEqual([]); @@ -1000,6 +1027,127 @@ describe('AgentTowerService', () => { } }); + it('enter() includes the live owner session title in the owned-by-live-session result', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-enter-owner-title-')); + try { + await initGitRepo(repo); + await writeFile(join(repo, 'README.md'), '# fixture\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); + await new TowerStore(repo).init('session-original'); + + stubLiveSession('session-original', { busy: true, title: 'Tower docs polish' }); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + await expect(tower.enter()).resolves.toEqual({ + entered: false, + reason: 'owned-by-live-session', + owner: 'session-original', + ownerTitle: 'Tower docs polish', + }); + + expect(tower.isActive).toBe(false); + expect(addedTools).toEqual([]); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + it('enter() bootstraps a non-git directory before preparing the requested base', async () => { + const dir = await mkdtemp(join(tmpdir(), 'tower-enter-nogit-')); + try { + await writeFile(join(dir, 'notes.md'), '# scratch\n'); + ix.stub(ISessionContext, { cwd: dir, sessionId: 'session-fresh' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + const result = await tower.enter('tower-base'); + + expect(result).toEqual({ entered: true }); + expect(tower.isActive).toBe(true); + const { stdout: subject } = await execFileAsync('git', ['log', '-1', '--format=%s'], { + cwd: dir, + }); + expect(subject.trim()).toBe( + 'tower: snapshot of uncommitted base checkout changes (base tower-base)', + ); + const { stdout: branch } = await execFileAsync('git', ['symbolic-ref', '--short', 'HEAD'], { + cwd: dir, + }); + expect(branch.trim()).toBe('tower-base'); + const { stdout: tracked } = await execFileAsync('git', ['ls-files'], { cwd: dir }); + expect(tracked).toContain('notes.md'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('enter() degrades to the owner id when the live owner has only a placeholder title', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-enter-owner-untitled-')); + try { + await initGitRepo(repo); + await writeFile(join(repo, 'README.md'), '# fixture\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); + await new TowerStore(repo).init('session-original'); + + stubLiveSession('session-original', { busy: true, title: 'New Session' }); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + const result = await tower.enter(); + + expect(result).toEqual({ + entered: false, + reason: 'owned-by-live-session', + owner: 'session-original', + ownerTitle: undefined, + }); + if (!result.entered) { + expect(towerEnterFailureMessage(result)).toBe( + 'another live session owns the workspace tower (session session-original)', + ); + } + expect(tower.isActive).toBe(false); + expect(addedTools).toEqual([]); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + it('enter() degrades to the owner id when the live owner metadata cannot be read', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-enter-owner-unreadable-')); + try { + await initGitRepo(repo); + await writeFile(join(repo, 'README.md'), '# fixture\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); + await new TowerStore(repo).init('session-original'); + + stubLiveSession('session-original', { busy: true, metadataReadFails: true }); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + const result = await tower.enter(); + + expect(result).toEqual({ + entered: false, + reason: 'owned-by-live-session', + owner: 'session-original', + ownerTitle: undefined, + }); + if (!result.entered) { + expect(towerEnterFailureMessage(result)).toBe( + 'another live session owns the workspace tower (session session-original)', + ); + } + expect(tower.isActive).toBe(false); + expect(addedTools).toEqual([]); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + it('enter() takes the tower over from a live but idle owner session', async () => { const repo = await mkdtemp(join(tmpdir(), 'tower-enter-takeover-')); try { @@ -1044,6 +1192,74 @@ describe('AgentTowerService', () => { } }); + it('exit() releases workspace ownership recorded under this session', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-exit-release-')); + try { + await initGitRepo(repo); + await writeFile(join(repo, 'README.md'), '# fixture\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); + const store = new TowerStore(repo); + await store.init('session-main'); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-main' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + await tower.enter(); + tower.exit(); + + expect(tower.isActive).toBe(false); + await vi.waitFor(async () => { + expect((await store.load()).sessionId).toBeUndefined(); + }); + const log = await store.recentLog(5); + expect(log.some((line) => line.includes(' release ') && line.includes('session=session-main'))).toBe(true); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + it('exit() keeps workspace ownership recorded under another session', async () => { + const repo = await mkdtemp(join(tmpdir(), 'tower-exit-foreign-')); + try { + await initGitRepo(repo); + await writeFile(join(repo, 'README.md'), '# fixture\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); + const store = new TowerStore(repo); + await store.init('session-original'); + ix.stub(ISessionContext, { cwd: repo, sessionId: 'session-fork' } as unknown as ISessionContext); + const tower = ix.get(IAgentTowerService); + + await tower.enter(); + expect(tower.isActive).toBe(true); + + let releaseSettled = Promise.resolve(); + const originalRelease = TowerStore.prototype.release; + const releaseSpy = vi + .spyOn(TowerStore.prototype, 'release') + .mockImplementation(function (this: TowerStore, sessionId) { + const pending = originalRelease.call(this, sessionId); + releaseSettled = pending.then( + () => undefined, + () => undefined, + ); + return pending; + }); + try { + tower.exit(); + + await vi.waitFor(() => expect(releaseSpy).toHaveBeenCalledWith('session-fork')); + await releaseSettled; + expect(tower.isActive).toBe(false); + expect((await store.load()).sessionId).toBe('session-original'); + } finally { + releaseSpy.mockRestore(); + } + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + it('does not veto TodoList while the tower flag is off, even with tower mode persisted active', async () => { const tower = ix.get(IAgentTowerService); await tower.enter(); @@ -1069,14 +1285,14 @@ describe('AgentTowerService', () => { expect(removedTools).toEqual([]); }); - it('enter is inert on a non-main agent', async () => { + it('enter reports not-main-agent and is inert on a non-main agent', async () => { ix.stub( IAgentScopeContext, makeAgentScopeContext({ agentId: 'test-agent', agentScope: testWireScope('wire', 'tower-test'), generation: 0 }), ); const tower = ix.get(IAgentTowerService); - await tower.enter(); + await expect(tower.enter()).resolves.toEqual({ entered: false, reason: 'not-main-agent' }); expect(tower.isActive).toBe(false); expect(addedTools).toEqual([]); @@ -1384,16 +1600,35 @@ describe('AgentTowerService', () => { ); const restored = ix2.get(IAgentTowerService); - await restoreTestEventDispatcher( - dispatcher, - ix2.get(IAppendLogStore), - testWireScope('wire', 'tower-fork-restore'), - records, - ); + let releaseSettled = Promise.resolve(); + const originalRelease = TowerStore.prototype.release; + const releaseSpy = vi + .spyOn(TowerStore.prototype, 'release') + .mockImplementation(function (this: TowerStore, sessionId) { + const pending = originalRelease.call(this, sessionId); + releaseSettled = pending.then( + () => undefined, + () => undefined, + ); + return pending; + }); + try { + await restoreTestEventDispatcher( + dispatcher, + ix2.get(IAppendLogStore), + testWireScope('wire', 'tower-fork-restore'), + records, + ); - expect(restored.isActive).toBe(false); - expect(restoredAdded).toEqual([]); - expect(events).toContainEqual({ type: 'agent.status.updated', towerMode: false }); + expect(restored.isActive).toBe(false); + expect(restoredAdded).toEqual([]); + expect(events).toContainEqual({ type: 'agent.status.updated', towerMode: false }); + await vi.waitFor(() => expect(releaseSpy).toHaveBeenCalledWith('session-fork')); + await releaseSettled; + expect((await new TowerStore(repo).load()).sessionId).toBe('session-original'); + } finally { + releaseSpy.mockRestore(); + } } finally { await rm(repo, { recursive: true, force: true }); } @@ -2059,3 +2294,35 @@ describe('TowerModeInjection', () => { expect(lastTowerReminder(context)).toContain('Tower mode is active'); }); }); + +describe('towerEnterFailureMessage', () => { + it.each([ + [ + { entered: false, reason: 'not-main-agent' }, + 'tower mode is only supported by the main agent', + ], + [ + { entered: false, reason: 'experiment-off' }, + 'the tower experiment is disabled; enable it with KIMI_CODE_EXPERIMENTAL_TOWER=1 or `[experimental] tower = true` in config.toml', + ], + [ + { entered: false, reason: 'feature-not-assembled' }, + 'the tower feature is not assembled in this process; a restart is required', + ], + [ + { entered: false, reason: 'owned-by-live-session', owner: 'session-original' }, + 'another live session owns the workspace tower (session session-original)', + ], + [ + { + entered: false, + reason: 'owned-by-live-session', + owner: 'session-original', + ownerTitle: 'Tower docs polish', + }, + 'another live session owns the workspace tower (session Tower docs polish (session-original))', + ], + ] as [TowerEnterFailure, string][])('maps %o to its message', (failure, message) => { + expect(towerEnterFailureMessage(failure)).toBe(message); + }); +}); diff --git a/packages/agent-core-v2/test/features/tower/workerProfile.test.ts b/packages/agent-core-v2/test/features/tower/workerProfile.test.ts index 7f92a6dd544..f699dbb1577 100644 --- a/packages/agent-core-v2/test/features/tower/workerProfile.test.ts +++ b/packages/agent-core-v2/test/features/tower/workerProfile.test.ts @@ -36,10 +36,8 @@ describe('tower-worker profile', () => { } }); - it('keeps the coder summary policy and whenToUse', () => { + it('keeps the coder whenToUse', () => { const coder = builtinProfile('coder'); - expect(TOWER_WORKER_PROFILE_DEF.summaryPolicy).toEqual(coder.summaryPolicy); - expect(TOWER_WORKER_PROFILE_DEF.summaryPolicy).toBeDefined(); expect(TOWER_WORKER_PROFILE_DEF.whenToUse).toBe(coder.whenToUse); }); }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 7ac14b7f018..7ee0dd877a8 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -1221,7 +1221,7 @@ export class AgentTestContext { const workspaceId = 'test-workspace'; const agentTelemetry = this.root.accessor .get(ITelemetryService) - .withContext({ agent_id: agentId }); + .withContext({ agent_id: agentId, mode: 'agent' }); const sessionScope = `${bootstrap.scope('sessions')}/${workspaceId}/${sessionId}`; this.session = this.root.createChild(LifecycleScope.Session, sessionId, { seeds: collectScopeSeed( diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts index 320c51787d5..d52364d311f 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts @@ -167,17 +167,14 @@ function telemetryStub( ): ITelemetryService { return { _serviceBrand: undefined, - track: (event: string, properties?: TelemetryProperties) => { - events.push({ event, properties: properties ?? {} }); - }, track2: (event, properties) => { events.push({ event, properties: (properties as TelemetryProperties | undefined) ?? {} }); }, withContext: () => telemetryStub(events), setContext: () => {}, + getContext: () => ({}), addAppender: () => ({ dispose: () => {} }), removeAppender: () => {}, - setAppender: () => {}, setEnabled: () => {}, flush: async () => {}, shutdown: async () => {}, diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 5b9bbc9ae54..f8895d79ce0 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -85,7 +85,6 @@ import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; @@ -363,11 +362,6 @@ describe('AgentLifecycleService', () => { track2: () => {}, }) as unknown as ITelemetryService, } as unknown as ITelemetryService); - ix.stub(IAgentTelemetryContextService, { - _serviceBrand: undefined, - get: () => ({ mode: 'agent' }), - set: () => {}, - }); ix.stub(IHostEnvironment, { _serviceBrand: undefined } as IHostEnvironment); ix.stub(IHostFileSystem, { _serviceBrand: undefined } as IHostFileSystem); ix.stub(IHostClock, { _serviceBrand: undefined } as IHostClock); @@ -827,11 +821,11 @@ describe('AgentLifecycleService', () => { expect(records).toContainEqual({ event: 'yolo_toggle', - properties: { agent_id: 'main', enabled: true }, + properties: { agent_id: 'main', enabled: true, mode: 'agent' }, }); expect(records).toContainEqual({ event: 'yolo_toggle', - properties: { agent_id: sub.agentId, enabled: false }, + properties: { agent_id: sub.agentId, enabled: false, mode: 'agent' }, }); }); @@ -1263,6 +1257,56 @@ describe('AgentLifecycleService', () => { ); }); + it('records the bound profile in the subagent metadata at registration', async () => { + const svc = ix.get(IAgentLifecycleService); + + await svc + .create({ + agentId: 'child', + binding: { profile: 'coder', model: 'kimi-test' }, + labels: { parentAgentId: 'main' }, + }) + .catch(() => undefined); + + expect(registerAgent).toHaveBeenCalledWith( + 'child', + expect.objectContaining({ + type: 'sub', + labels: { parentAgentId: 'main', profileName: 'coder' }, + }), + ); + }); + + it('fork records the inherited profile in the subagent metadata', async () => { + const svc = ix.get(IAgentLifecycleService); + const source = await svc.create({ agentId: 'main' }); + svc.handleOf('main')!.accessor.get(IAgentProfileService).applyBindingSnapshot({ + profileName: 'coder', + thinkingLevel: 'off', + systemPrompt: 'coder prompt', + activeToolNames: ['Read'], + disallowedTools: [], + subagents: undefined, + }); + + await svc.fork(agentContextOf(svc.handleOf(source.agentId)!), { + agentId: 'forked', + labels: { parentAgentId: 'main' }, + }); + + expect(registerAgent).toHaveBeenCalledWith( + 'forked', + expect.objectContaining({ + forkedFrom: 'main', + labels: { parentAgentId: 'main', profileName: 'coder' }, + }), + ); + expect(registerAgent).toHaveBeenCalledWith( + 'main', + expect.objectContaining({ type: 'main', labels: undefined }), + ); + }); + it('run throws when the agent does not exist', () => { ix.set(ISessionSubagentService, new SyncDescriptor(SessionSubagentService)); const svc = ix.get(ISessionSubagentService); diff --git a/packages/agent-core-v2/test/session/subagent/forkParity.test.ts b/packages/agent-core-v2/test/session/subagent/forkParity.test.ts index dc104ec99aa..a7bcd0b3f7d 100644 --- a/packages/agent-core-v2/test/session/subagent/forkParity.test.ts +++ b/packages/agent-core-v2/test/session/subagent/forkParity.test.ts @@ -112,10 +112,7 @@ class TestRuntimeResolver implements IRuntimeResolver { const PARENT_SYSTEM_PROMPT = 'You are the parity probe parent.'; const ACTIVE_TOOL_NAMES = ['Agent', 'Bash', 'Read']; -const CHILD_FINAL_TEXT = - 'The inherited task is done. This closing summary is intentionally long so that any ' + - 'profile summary policy with a minimum character threshold considers it adequate and no ' + - 'extra continuation request is scripted for the child agent turn.'; +const CHILD_FINAL_TEXT = 'The inherited task is done.'; describe('fork subagent first-request parity', () => { let ctx: TestAgentContext; diff --git a/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts b/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts new file mode 100644 index 00000000000..336905ffbfb --- /dev/null +++ b/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { + createMaxStepsExceededError, + IAgentLoopService, + type Turn, + type TurnResult, +} from '#/agent/loop/loop'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { ErrorCodes, isError2 } from '#/errors'; +import { runAgentTurn } from '#/session/subagent/runAgentTurn'; + +function makeTurn(result: TurnResult): Turn { + const controller = new AbortController(); + return { + id: 1, + signal: controller.signal, + ready: Promise.resolve(), + result: Promise.resolve(result), + cancel: () => { + controller.abort(); + return true; + }, + }; +} + +function assistant(text: string): ContextMessage { + return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] }; +} + +function handleWith( + turn: Turn, + messages: readonly ContextMessage[], +): { readonly handle: IAgentScopeHandle; readonly prompts: unknown[] } { + const prompts: unknown[] = []; + const services = new Map([ + [ + IAgentPromptService, + { + enqueue: async (input: unknown) => { + prompts.push(input); + return { launched: Promise.resolve(turn) }; + }, + retry: async () => turn, + }, + ], + [IAgentLoopService, { cancel: () => true }], + [IAgentContextMemoryService, { get: () => messages }], + ]); + const handle: IAgentScopeHandle = { + id: 'agent-child', + kind: LifecycleScope.Agent, + accessor: { + get: ((id: unknown) => services.get(id)) as IAgentScopeHandle['accessor']['get'], + }, + dispose: () => {}, + }; + return { handle, prompts }; +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + throw new Error('expected rejection'); +} + +describe('runAgentTurn', () => { + const signal = new AbortController().signal; + + it('returns a short final message as is without a continuation prompt', async () => { + const { handle, prompts } = handleWith( + makeTurn({ type: 'completed', steps: 1, truncated: false }), + [assistant('src/a.ts:12')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + await expect(run.completion).resolves.toMatchObject({ summary: 'src/a.ts:12' }); + expect(prompts).toHaveLength(1); + }); + + it('carries the loop stop reason alongside the handoff text', async () => { + const { handle } = handleWith( + makeTurn({ type: 'completed', steps: 13, truncated: false, stopReason: 'repeat_breaker' }), + [assistant('Stuck: the same grep keeps returning nothing.')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + await expect(run.completion).resolves.toMatchObject({ + summary: 'Stuck: the same grep keeps returning nothing.', + stopReason: 'repeat_breaker', + }); + }); + + it('fails with agent.no_final_message when the turn ends without text', async () => { + const { handle } = handleWith( + makeTurn({ type: 'completed', steps: 13, truncated: false, stopReason: 'repeat_breaker' }), + [assistant('')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + const error = await rejection(run.completion); + expect(isError2(error) && error.code).toBe(ErrorCodes.AGENT_NO_FINAL_MESSAGE); + expect((error as Error).message).toContain('stop reason: repeat_breaker'); + }); + + it('rewrites the step-cap failure into a model-facing message', async () => { + const { handle } = handleWith( + makeTurn({ type: 'failed', steps: 5, error: createMaxStepsExceededError(5) }), + [assistant('partial work')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + const error = await rejection(run.completion); + expect(isError2(error) && error.code).toBe(ErrorCodes.LOOP_MAX_STEPS_EXCEEDED); + expect((error as Error).message).toContain('maxSteps=5'); + expect((error as Error).message).not.toContain('config.toml'); + }); + + it('still reports max tokens as a failure', async () => { + const { handle } = handleWith( + makeTurn({ type: 'completed', steps: 1, truncated: true }), + [assistant('cut off')], + ); + const run = await runAgentTurn(handle, { kind: 'prompt', prompt: 'find it' }, { signal }); + const error = await rejection(run.completion); + expect(isError2(error) && error.code).toBe(ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED); + }); +}); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 4adefe5a929..78a555f9a62 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -44,6 +44,8 @@ import { IAgentReminderService } from '#/features/reminder/reminderService'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, SECONDARY_MODEL_SECTION, SUBAGENT_SECTION } from '#/session/subagent/configSection'; import { SUBAGENT_FORK_FLAG_ID } from '#/session/subagent/flag'; import { Error2, ErrorCodes } from '#/errors'; +import type { AgentTaskSettlement } from '#/agent/task/types'; +import { SubagentTask } from '#/agent/tools/agent/subagent-task'; import { runAgentTurn } from '#/session/subagent/runAgentTurn'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import type { AgentContext } from '#/agent/agentContext/agentContext'; @@ -52,6 +54,7 @@ import { type AgentScopeCreatedEvent, } from '#/session/agentLifecycle/agentLifecycle'; import { + type AgentRunCompletion, type AgentRunHandle, type AgentRunRequest, type AgentTaskStopHookContext, @@ -98,6 +101,7 @@ import { import { executeTool } from '../tools/fixtures/execute-tool'; import { stubAgentContext } from '../agent/agentContext/stubs'; import { agentContextOf } from '#/agent/scopeContext/scopeContext'; +import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; const signal = new AbortController().signal; @@ -211,7 +215,7 @@ interface AgentLifecycleStubOptions { agentId: string, request: AgentRunRequest, options: RunAgentOptions, - ) => Promise<{ readonly summary: string; readonly usage?: TokenUsage }>; + ) => Promise; readonly createError?: Error; readonly handleServices?: ReadonlyMap>; } @@ -1337,6 +1341,239 @@ describe('Agent tool execution contract', () => { expect(result.output).toContain('actual_subagent_type: explore'); }); + it('reports a normal completion with stop_reason and a resume hint', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => ({ summary: 'child result' }), + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBeUndefined(); + expect(result.output).toContain('status: completed'); + expect(result.output).toContain('stop_reason: completed'); + expect(result.output).toContain('[summary]\nchild result'); + expect(result.output).toContain('resume_hint: Continue with Agent(resume="agent-child"'); + expect(result.output).not.toContain('notice:'); + expect(result.output).not.toContain('next_step:'); + }); + + it('reports a repeat-breaker handoff as completed with stop_reason repeat_breaker', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => ({ + summary: 'Stuck: the same grep keeps returning nothing.', + stopReason: 'repeat_breaker', + }), + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBeUndefined(); + expect(result.output).toContain('status: completed'); + expect(result.output).toContain('stop_reason: repeat_breaker'); + expect(result.output).toContain('notice: The subagent was stopped by the repeat breaker'); + expect(result.output).toContain('[summary]\nStuck: the same grep keeps returning nothing.'); + expect(result.output).toContain('next_step: The subagent was stuck on one tool call.'); + }); + + it('settles a repeat-breaker completion with a stop code and a task reason', async () => { + const task = new SubagentTask( + { + agentId: 'agent-child', + profileName: 'coder', + completion: Promise.resolve({ result: 'handoff', stopReason: 'repeat_breaker' }), + }, + 'Find cause', + new AbortController(), + ); + const settlements: AgentTaskSettlement[] = []; + const output: string[] = []; + await task.start({ + signal: new AbortController().signal, + appendOutput: (chunk) => { + output.push(chunk); + }, + settle: async (settlement) => { + settlements.push(settlement); + return true; + }, + }); + + expect(output).toEqual(['handoff']); + expect(settlements).toEqual([ + { status: 'completed', stopReason: expect.stringContaining('repeat breaker') }, + ]); + const info = task.toInfo({ + taskId: 'agent-1', + description: 'Find cause', + status: 'completed', + startedAt: 0, + endedAt: 1, + }); + expect(info.stopCode).toBe('repeat_breaker'); + }); + + it('derives the stop code from the stop reason of a missing-handoff failure', async () => { + const task = new SubagentTask( + { + agentId: 'agent-child', + profileName: 'coder', + completion: Promise.reject( + new Error2(ErrorCodes.AGENT_NO_FINAL_MESSAGE, 'no handoff', { + details: { stopReason: 'repeat_breaker' }, + }), + ), + }, + 'Find cause', + new AbortController(), + ); + const settlements: AgentTaskSettlement[] = []; + await task.start({ + signal: new AbortController().signal, + appendOutput: () => {}, + settle: async (settlement) => { + settlements.push(settlement); + return true; + }, + }); + + expect(settlements).toEqual([{ status: 'failed', stopReason: 'no handoff' }]); + const info = task.toInfo({ + taskId: 'agent-1', + description: 'Find cause', + status: 'failed', + startedAt: 0, + endedAt: 1, + }); + expect(info.stopCode).toBe('repeat_breaker'); + }); + + it('reports a missing final message as a failure with stop_reason no_final_message', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error2( + ErrorCodes.AGENT_NO_FINAL_MESSAGE, + 'Subagent turn ended without a final message (stop reason: repeat_breaker).', + ); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('status: failed'); + expect(result.output).toContain('stop_reason: no_final_message'); + expect(result.output).toContain( + 'subagent error: The subagent was stopped before it finished. Reason: Subagent turn ended without a final message (stop reason: repeat_breaker).', + ); + expect(result.output).toContain('resume_hint: Continue with Agent(resume="agent-child", prompt="continue")'); + expect(result.output).toContain('next_step: Resume to continue where it stopped'); + }); + + it('keeps the repeat_breaker classification when the handoff produced no text', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error2( + ErrorCodes.AGENT_NO_FINAL_MESSAGE, + 'Subagent turn ended without a final message (stop reason: repeat_breaker).', + { details: { stopReason: 'repeat_breaker' } }, + ); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('status: failed'); + expect(result.output).toContain('stop_reason: repeat_breaker'); + expect(result.output).toContain('Reason: Subagent turn ended without a final message'); + expect(result.output).toContain('resume_hint: Continue with Agent(resume="agent-child", prompt="continue")'); + expect(result.output).toContain('next_step: The subagent was stuck on one tool call.'); + expect(result.output).not.toContain('[summary]'); + }); + + it('maps a step-cap failure to stop_reason max_steps without config advice', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error2( + ErrorCodes.LOOP_MAX_STEPS_EXCEEDED, + 'Subagent hit the per-turn step cap (maxSteps=5) before finishing its handoff.', + ); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('stop_reason: max_steps'); + expect(result.output).toContain('maxSteps=5'); + expect(result.output).not.toContain('config.toml'); + expect(result.output).toContain('resume_hint:'); + }); + + it('maps a provider filter failure to stop_reason filtered with a rephrase hint', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error2(ErrorCodes.PROVIDER_FILTERED, 'Provider safety policy blocked the response.'); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('stop_reason: filtered'); + expect(result.output).toContain('next_step: Resuming is unlikely to help'); + }); + + it('truncates an oversized failure reason', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error('x'.repeat(5000)); + }, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('stop_reason: error'); + expect(result.output).toContain('[truncated]'); + expect((result.output as string).length).toBeLessThan(3000); + }); + it('declares no resource accesses so concurrent Agent calls can run in parallel', async () => { const context = createAgentToolContext(); @@ -1366,6 +1603,48 @@ describe('Agent tool execution contract', () => { expect(lifecycle.list).toHaveBeenCalled(); }); + it('uses the persisted profile of an offline subagent for display and approval rules', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { labels: { parentAgentId: 'main', profileName: 'explore' } }, + }), + ), + ); + + const execution = await agentTool(context).resolveExecution({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Launching explore agent: Continue work'); + expect(execution.matchesRule?.('explore')).toBe(true); + expect(execution.matchesRule?.('coder')).toBe(false); + expect(lifecycle.create).not.toHaveBeenCalled(); + }); + + it('falls back to the generic label when an offline subagent has no persisted profile', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({ 'agent-existing': subagentMeta() })), + ); + + const execution = await agentTool(context).resolveExecution({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Launching subagent agent: Continue work'); + }); + it('labels fork launches with the caller profile for display and approval rules', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext(lifecycle, forkFlags()); @@ -2086,6 +2365,178 @@ describe('Agent tool execution contract', () => { expect(result.output).toContain('resumed result'); }); + it('rebuilds a persisted subagent that is not live before resuming it', async () => { + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed after restart' }), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { + type: 'sub', + parentAgentId: 'main', + forkedFrom: 'main', + labels: { parentAgentId: 'main' }, + }, + }), + ), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(lifecycle.create).toHaveBeenCalledTimes(1); + expect(lifecycle.create).toHaveBeenCalledWith({ + agentId: 'agent-existing', + labels: { parentAgentId: 'main' }, + forkedFrom: 'main', + }); + expect(lifecycle.run).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-existing' }), + { kind: 'prompt', prompt: 'Continue' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.isError).not.toBe(true); + expect(result.output).toContain('agent_id: agent-existing'); + expect(result.output).toContain('resumed after restart'); + }); + + it('keeps rejecting resume of an agent id that was never persisted', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({})), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-missing', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: Agent instance "agent-missing" does not exist', + }); + expect(lifecycle.create).not.toHaveBeenCalled(); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + + it('does not rebuild a persisted subagent owned by another parent', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ 'agent-existing': subagentMeta('other') }), + ), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: Agent instance "agent-existing" does not belong to this parent agent', + }); + expect(lifecycle.create).not.toHaveBeenCalled(); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + + it('syncs a rebuilt subagent to the caller permission mode before resuming it', async () => { + const setMode = vi.fn(); + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed after restart' }), + handleServices: new Map>([ + [ + 'agent-existing', + new Map([ + [ + IAgentPermissionModeService, + { _serviceBrand: undefined, mode: 'yolo', setMode, onDidChangeMode: Event.None }, + ], + ]), + ], + ]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({ 'agent-existing': subagentMeta() })), + ); + context.get(IAgentPermissionModeService).setMode('auto'); + expect(context.get(IAgentPermissionModeService).mode).toBe('auto'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result.isError).not.toBe(true); + expect(setMode).toHaveBeenCalledWith('auto'); + expect(setMode.mock.invocationCallOrder[0]).toBeLessThan( + lifecycle.run.mock.invocationCallOrder[0]!, + ); + }); + + it('keeps a rebuilt tower worker on its pinned permission mode', async () => { + const setMode = vi.fn(); + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'worker resumed' }), + handleServices: new Map>([ + [ + 'agent-existing', + new Map([ + [ + IAgentProfileService, + { + _serviceBrand: undefined, + data: () => ({ profileName: TOWER_WORKER_PROFILE }), + update: () => {}, + republishStatus: () => {}, + getEffectiveThinkingLevel: () => 'off', + getActiveToolNames: () => [], + isToolActive: () => false, + }, + ], + [ + IAgentPermissionModeService, + { _serviceBrand: undefined, mode: 'auto', setMode, onDidChangeMode: Event.None }, + ], + ]), + ], + ]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE } }, + }), + ), + ); + context.get(IAgentPermissionModeService).setMode('manual'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result).toEqual({ output: expect.stringContaining(`actual_subagent_type: ${TOWER_WORKER_PROFILE}`) }); + expect(setMode).not.toHaveBeenCalled(); + expect(lifecycle.run).toHaveBeenCalledOnce(); + }); + it('rejects direct resume of a non-subagent', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext( @@ -2600,7 +3051,10 @@ describe('Agent tool execution contract', () => { expect(result.isError).toBe(true); expect(result.output).toContain('status: failed'); + expect(result.output).toContain('stop_reason: cancelled'); expect(result.output).toContain('The subagent was stopped before it finished by user.'); + expect(result.output).not.toContain('resume_hint:'); + expect(result.output).toContain('next_step: The user stopped this subagent.'); }); it('reports the reason when a foreground subagent is stopped for another cause', async () => { @@ -2627,9 +3081,12 @@ describe('Agent tool execution contract', () => { const result = await resultPromise; expect(result.isError).toBe(true); + expect(result.output).toContain('stop_reason: stopped'); expect(result.output).toContain( 'The subagent was stopped before it finished. Reason: Session closed', ); + expect(result.output).toContain('resume_hint: Continue with Agent(resume="agent-child"'); + expect(result.output).not.toContain('The user stopped this subagent'); }); it('returns the spawned agent id when a foreground subagent times out', async () => { @@ -3267,6 +3724,56 @@ describe('AgentSwarm tool execution contract', () => { expect(result.isError).toBeUndefined(); }); + it('renders a handoff stop reason on a completed subagent and offers a resume hint', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => [ + { + task: args.tasks[0]!, + agentId: 'agent-coder-1', + status: 'completed' as const, + result: 'imports are stable', + }, + { + task: args.tasks[1]!, + agentId: 'agent-coder-2', + status: 'completed' as const, + result: 'Stuck: the same grep keeps returning nothing.', + stopReason: 'repeat_breaker', + }, + ], + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + signal, + }); + + expect(result.output).toBe([ + '', + 'completed: 2', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'imports are stable', + 'Stuck: the same grep keeps returning nothing.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + it('reports partial aborted subagents inside the XML result', async () => { const runSwarm = vi.fn( async ( diff --git a/packages/agent-core-v2/test/wire/resume.test.ts b/packages/agent-core-v2/test/wire/resume.test.ts index c239c73766d..4770955d66d 100644 --- a/packages/agent-core-v2/test/wire/resume.test.ts +++ b/packages/agent-core-v2/test/wire/resume.test.ts @@ -164,6 +164,58 @@ describe('Agent resume', () => { } }); + it('closes a restored open tool call with an interrupted result before the next prompt', async () => { + const persistence = new RecordingAgentPersistence([ + resumeConfigRecord(), + contextAppendRecord(0, [{ role: 'user', text: 'Run lookup', origin: { kind: 'user' } }]), + turnPromptRecord(0, { kind: 'user' }), + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'step-0', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'call-0', + turnId: '0', + step: 1, + stepUuid: 'step-0', + toolCallId: 'call_open', + name: 'Lookup', + args: { query: 'moon' }, + }, + }, + ] as unknown as WireRecord[]); + const ctx = testAgent({ persistence, autoConfigure: false }); + + try { + await ctx.restorePersisted(); + + expect(ctx.llmCalls).toHaveLength(0); + + ctx.mockNextResponse({ type: 'text', text: 'Fresh response after resume.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fresh prompt after resume' }] }); + await ctx.untilTurnEnd(); + + expect(findRpcEvent(ctx.allEvents, 'error')).toBeUndefined(); + expect(ctx.llmInputs()).toMatchInlineSnapshot(` + call 1: + system: + tools: Agent, AgentSwarm, AskUserQuestion, Bash, CreateGoal, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, FetchURL, GetGoal, Glob, Grep, Read, SetGoalBudget, Skill, TaskList, TaskOutput, TaskStop, TodoList, UpdateGoal, WaitFor, Write + messages: + user: text "Run lookup" + assistant: [] calls call_open:Lookup { "query": "moon" } + tool[call_open]: text "ERROR: Tool execution failed.\\nTool execution was interrupted before its result was recorded. Do not assume the tool completed successfully." + user: text "Fresh prompt after resume" + user: text + `); + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + it('does not reconcile a legacy interruption whose delivery was recorded', async () => { const persistence = new RecordingAgentPersistence([ resumeConfigRecord(), diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index 9a9c5b178b4..0be2fd85a16 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -311,17 +311,14 @@ function makeStreamingProcess(lines: readonly string[]): { function telemetryStub(events: Array<{ event: string; properties: Record }>): ITelemetryService { return { _serviceBrand: undefined, - track: (event: string, properties?: TelemetryProperties) => { - events.push({ event, properties: properties ?? {} }); - }, track2: (event, properties) => { events.push({ event, properties: (properties as TelemetryProperties | undefined) ?? {} }); }, withContext: () => telemetryStub(events), setContext: () => {}, + getContext: () => ({}), addAppender: () => ({ dispose: () => {} }), removeAppender: () => {}, - setAppender: () => {}, setEnabled: () => {}, flush: async () => {}, shutdown: async () => {}, diff --git a/packages/kap-server/src/lib/promptMedia.ts b/packages/kap-server/src/lib/promptMedia.ts index 6b5484ffc56..2f991b7466b 100644 --- a/packages/kap-server/src/lib/promptMedia.ts +++ b/packages/kap-server/src/lib/promptMedia.ts @@ -24,7 +24,6 @@ import { type GetResult, type IFileService, type ISessionMediaStore, - type ImageCompressionTelemetry, type ITelemetryService, type PromptFileAttachment, } from '@moonshot-ai/agent-core-v2'; @@ -162,8 +161,6 @@ export async function resolvePromptMediaFiles( } return attachmentsDir ?? cacheDir; }; - const telemetryFor = (source: string): ImageCompressionTelemetry | undefined => - options.telemetry === undefined ? undefined : { client: options.telemetry, source }; const attachments: PromptFileAttachment[] = []; const content: WireContent = []; try { @@ -195,7 +192,8 @@ export async function resolvePromptMediaFiles( } const canonicalMime = normalizeImageMime(effectiveMime); const compressed = await compressBase64ForModel(part.source.data, canonicalMime, { - telemetry: telemetryFor('prompt_inline'), + telemetry: options.telemetry, + telemetrySource: 'prompt_inline', }); if (compressed.changed) { const dir = await resolveOriginalsDir(); @@ -307,7 +305,8 @@ export async function resolvePromptMediaFiles( } mediaType = normalizeImageMime(mediaType); const compressed = await compressImageForModel(data, mediaType, { - telemetry: telemetryFor('prompt_file'), + telemetry: options.telemetry, + telemetrySource: 'prompt_file', }); if (compressed.changed) { content.push({ @@ -401,7 +400,8 @@ export async function resolvePromptMediaFiles( } mediaType = normalizeImageMime(mediaType); const compressed = await compressImageForModel(data, mediaType, { - telemetry: telemetryFor('prompt_file'), + telemetry: options.telemetry, + telemetrySource: 'prompt_file', }); if (compressed.changed) { const dir = await resolveOriginalsDir(); diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index daa9ec6d5dd..a770aef69ed 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -257,7 +257,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { req.body.model ?? (switchingProfile ? undefined : sessionModel || undefined), ); - const telemetry = core.accessor.get(ITelemetryService).withContext({ sessionId: session_id }); + const telemetry = core.accessor.get(ITelemetryService).withContext({ session_id }); preparedMedia = await resolvePromptMediaFiles( req.body.content, core.accessor.get(IFileService), diff --git a/packages/kap-server/src/routes/sessionAgentConfig.ts b/packages/kap-server/src/routes/sessionAgentConfig.ts index f104609dd66..bcf1915b4c2 100644 --- a/packages/kap-server/src/routes/sessionAgentConfig.ts +++ b/packages/kap-server/src/routes/sessionAgentConfig.ts @@ -8,6 +8,7 @@ import { IAgentSwarmService, IAgentTowerService, resumeSessionById, + towerEnterFailureMessage, type PermissionMode, type Scope, } from '@moonshot-ai/agent-core-v2'; @@ -56,11 +57,11 @@ export async function applySessionAgentConfig( if (agentConfig.tower_mode !== undefined) { const tower = agent.accessor.get(IAgentTowerService); if (agentConfig.tower_mode) { - await tower.enter(agentConfig.tower_base); - if (!tower.isActive) { + const result = await tower.enter(agentConfig.tower_base); + if (!result.entered) { throw new Error2( ErrorCodes.SESSION_TOWER_MODE_INVALID, - 'tower mode could not be enabled — another live session owns the workspace tower', + towerEnterFailureMessage(result), ); } } else { diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 7c79dcb2d78..16b37e66641 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -252,7 +252,7 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void { attachments, resolved.handle.accessor.get(ISessionMediaStore), ); - const telemetry = core.accessor.get(ITelemetryService).withContext({ sessionId: session_id }); + const telemetry = core.accessor.get(ITelemetryService).withContext({ session_id }); const sessionDir = resolved.handle.accessor.get(ISessionContext).sessionDir; preparedMedia = await resolvePromptMediaFiles( attachments, diff --git a/packages/kap-server/src/services/pinoLoggerService.ts b/packages/kap-server/src/services/pinoLoggerService.ts index 65aaa89cb07..39d9a28ae6d 100644 --- a/packages/kap-server/src/services/pinoLoggerService.ts +++ b/packages/kap-server/src/services/pinoLoggerService.ts @@ -1,4 +1,4 @@ -import { pino, type Logger, type LoggerOptions } from 'pino'; +import { pino, type DestinationStream, type Logger, type LoggerOptions } from 'pino'; export type ServerLogger = Logger; @@ -6,6 +6,7 @@ export type ServerLogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'tr export interface CreateLoggerOptions { level: ServerLogLevel; + stream?: DestinationStream; } export function createServerLogger(opts: CreateLoggerOptions): ServerLogger { @@ -14,5 +15,5 @@ export function createServerLogger(opts: CreateLoggerOptions): ServerLogger { base: { name: 'kimi-server-v2' }, timestamp: pino.stdTimeFunctions.isoTime, }; - return pino(base); + return opts.stream === undefined ? pino(base) : pino(base, opts.stream); } diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index a1c3cb0e334..e35bf625c76 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -171,11 +171,10 @@ export async function startServer(opts: ServerStartOptions): Promise { - logger.fatal( + logger.error( { err: err instanceof Error ? err : new Error(String(err)) }, 'uncaughtException', ); - process.exit(1); }; const authFailureLimiter = exposureClass === 'loopback' ? undefined : createAuthFailureLimiter({ logger }); diff --git a/packages/kap-server/test/boot.test.ts b/packages/kap-server/test/boot.test.ts index 4f66a7895cd..a0a7145aec4 100644 --- a/packages/kap-server/test/boot.test.ts +++ b/packages/kap-server/test/boot.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Writable } from 'node:stream'; import { pino } from 'pino'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -224,7 +225,7 @@ describe('server-v2 boot', () => { ], }); const core = server.core; - core.accessor.get(ITelemetryService).track('server_probe'); + core.accessor.get(ITelemetryService).track2('session_ended', { reason: 'exit' }); await server.close(); server = undefined; @@ -233,26 +234,56 @@ describe('server-v2 boot', () => { expect(await listLiveServerInstances(home)).toEqual([]); }); - it('installs process-level rejection handlers while running and removes them on close', async () => { + it('logs process-level exceptions without exiting and removes the handlers on close', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-')); - const rejectionBefore = process.listenerCount('unhandledRejection'); - const exceptionBefore = process.listenerCount('uncaughtException'); + const lines: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + lines.push(String(chunk)); + callback(); + }, + }); + const rejectionBefore = process.listeners('unhandledRejection'); + const exceptionBefore = process.listeners('uncaughtException'); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, - logLevel: 'silent', + logger: pino({ level: 'error' }, stream), }); - expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore + 1); - expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore + 1); + expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore.length + 1); + expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore.length + 1); + + const onUncaughtException = process + .listeners('uncaughtException') + .find((listener) => !exceptionBefore.includes(listener)) as + | ((error: Error) => void) + | undefined; + const onUnhandledRejection = process + .listeners('unhandledRejection') + .find((listener) => !rejectionBefore.includes(listener)) as + | ((reason: unknown) => void) + | undefined; + expect(onUncaughtException).toBeDefined(); + expect(onUnhandledRejection).toBeDefined(); + + onUncaughtException?.(new Error('synthetic uncaught')); + onUnhandledRejection?.(new Error('synthetic rejection')); + + const output = lines.join(''); + expect(output).toContain('"msg":"uncaughtException"'); + expect(output).toContain('"msg":"unhandledRejection"'); + + const healthz = await fetch(`http://127.0.0.1:${server.port}/api/v1/healthz`); + expect(healthz.status).toBe(200); await server.close(); server = undefined; - expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore); - expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore); + expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore.length); + expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore.length); }); it('does not leave process handlers installed when startup fails', async () => { diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index ba143f6acb9..66c7921663a 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -777,8 +777,9 @@ describe('server-v2 /api/v1/sessions', () => { const on = await postJson(`/api/v1/sessions/${id}/profile`, { agent_config: { tower_mode: true }, }); - expect(on.body.code).not.toBe(0); - expect(on.body.msg).toContain('tower mode could not be enabled'); + expect(on.body.code).toBe(50001); + expect(on.body.msg).toContain('the tower experiment is disabled'); + expect(on.body.msg).toContain('KIMI_CODE_EXPERIMENTAL_TOWER=1'); const after = await getJson<{ tower_mode?: boolean; }>(`/api/v1/sessions/${id}/status`); diff --git a/packages/kap-server/test/telemetry.test.ts b/packages/kap-server/test/telemetry.test.ts index b8b8ad9d9da..68aca25afb6 100644 --- a/packages/kap-server/test/telemetry.test.ts +++ b/packages/kap-server/test/telemetry.test.ts @@ -82,7 +82,7 @@ describe('server telemetry', () => { vi.stubGlobal('fetch', cloudFetch); const hostEvents: string[] = []; const hostAppender: ITelemetryAppender = { - track: (event) => hostEvents.push(event), + track: (record) => hostEvents.push(record.event), }; const hostTelemetry = new TelemetryService(); hostTelemetry.addAppender(hostAppender); @@ -90,16 +90,16 @@ describe('server telemetry', () => { const telemetry = await initializeServerTelemetry(app, home as string); const service = app.accessor.get(ITelemetryService); - service.track('server_probe'); + service.track2('session_ended', { reason: 'exit' }); - expect(hostEvents).toEqual(['server_probe']); + expect(hostEvents).toEqual(['session_ended']); await shutdownServerTelemetry(telemetry); - service.track('host_after_server_shutdown'); + service.track2('session_ended', { reason: 'archive' }); await service.flush(); expect(cloudFetch).toHaveBeenCalledOnce(); - expect(hostEvents).toEqual(['server_probe', 'host_after_server_shutdown']); + expect(hostEvents).toEqual(['session_ended', 'session_ended']); }); it('returns at the deadline when cloud delivery never settles', async () => { @@ -109,7 +109,7 @@ describe('server telemetry', () => { } as unknown as IOAuthToolkit; const app = await bootCore(undefined, undefined, [[IOAuthToolkit, auth]]); const telemetry = await initializeServerTelemetry(app, home as string); - app.accessor.get(ITelemetryService).track('server_probe'); + app.accessor.get(ITelemetryService).track2('session_ended', { reason: 'exit' }); await expect(shutdownServerTelemetry(telemetry, Date.now())).resolves.toBeUndefined(); }); diff --git a/packages/kap-server/test/transcriptContract.e2e.test.ts b/packages/kap-server/test/transcriptContract.e2e.test.ts index ba94a4e1255..1e5af24d4b7 100644 --- a/packages/kap-server/test/transcriptContract.e2e.test.ts +++ b/packages/kap-server/test/transcriptContract.e2e.test.ts @@ -6,6 +6,12 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket, type RawData } from 'ws'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, + getLiveSessionById, + resumeSessionById, +} from '@moonshot-ai/agent-core-v2'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; @@ -487,4 +493,83 @@ describe('transcript contract e2e', () => { ); channel.close(); }); + + it('S7: a foreground subagent resumes with its prior context after a server restart', async () => { + let childAgentId: string | undefined; + let resumedChildRequest: string | undefined; + await boot([ + { + match: (body) => body.includes('spawn-child') && !body.includes('"role":"tool"'), + respond: () => + sseToolCall( + 'call_spawn', + 'Agent', + JSON.stringify({ prompt: 'remember the token quartz-7731 and reply with ok', description: 'child' }), + ), + }, + { + match: (body) => + body.includes('remember the token') && !body.includes('spawn-child') && !body.includes('recall the token'), + respond: () => sseText('ok, remembered'), + }, + { + match: (body) => body.includes('resume-child') && !body.includes('recall the token'), + respond: () => + sseToolCall( + 'call_resume', + 'Agent', + JSON.stringify({ prompt: 'recall the token', description: 'child again', resume: childAgentId }), + ), + }, + { + match: (body) => { + const hit = body.includes('recall the token') && !body.includes('resume-child'); + if (hit) resumedChildRequest = body; + return hit; + }, + respond: () => sseText('the token is quartz-7731'), + }, + { match: () => true, respond: () => sseText('noted') }, + ]); + const sid = await createSession(server!, base); + await submitPrompt(server!, base, sid, 'spawn-child now'); + await idle(server!, base, sid); + + const liveBefore = getLiveSessionById(server!.core.accessor, sid); + expect(liveBefore).toBeDefined(); + const childIds = liveBefore!.accessor + .get(IAgentLifecycleService) + .list() + .map((agent) => agent.agentId) + .filter((id) => id !== MAIN_AGENT_ID); + expect(childIds).toHaveLength(1); + childAgentId = childIds[0]; + + await server!.close(); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home!, logLevel: 'silent' }); + base = `http://127.0.0.1:${server.port}`; + + const resumed = await resumeSessionById(server.core.accessor, sid); + expect(resumed).toBeDefined(); + const agents = resumed!.accessor.get(IAgentLifecycleService); + expect(agents.handleOf(childAgentId!)).toBeUndefined(); + + await submitPrompt(server, base, sid, 'resume-child now'); + await idle(server, base, sid); + + expect(resumedChildRequest).toBeDefined(); + expect(resumedChildRequest).toContain('quartz-7731'); + expect(resumedChildRequest).toContain('ok, remembered'); + expect(agents.handleOf(childAgentId!)).toBeDefined(); + + const end = await getTranscript(server, base, sid); + const agentFrames = end.items + .filter((i) => i.kind === 'turn') + .flatMap((t: any) => t.steps) + .flatMap((s: any) => s.frames) + .filter((f: any) => f.kind === 'tool' && f.name === 'Agent'); + expect(agentFrames).toHaveLength(2); + expect(String(agentFrames[1].output)).toContain(`agent_id: ${childAgentId}`); + expect(String(agentFrames[1].output)).toContain('the token is quartz-7731'); + }, 60000); }, 90000); diff --git a/packages/migration-legacy/src/index.ts b/packages/migration-legacy/src/index.ts index 6689064a230..999f305b46b 100644 --- a/packages/migration-legacy/src/index.ts +++ b/packages/migration-legacy/src/index.ts @@ -6,7 +6,6 @@ export { shouldSuppressMigration, type MigrationSuppressionInput, } from './marker.js'; -export { countImportedSessionsNeedingRepair } from './sessions/repair-imported.js'; export { defaultPlansSourceDir } from './steps/plans.js'; export { runMigration, type RunMigrationInput } from './run-migration.js'; export { diff --git a/packages/migration-legacy/src/run-migration.ts b/packages/migration-legacy/src/run-migration.ts index fdbeddbd993..50a97090a6e 100644 --- a/packages/migration-legacy/src/run-migration.ts +++ b/packages/migration-legacy/src/run-migration.ts @@ -189,7 +189,6 @@ function emptyConfigOnlySessions(): SessionsSummary { sessionsAttempted: 0, sessionsMigrated: 0, sessionsAlreadyMigrated: 0, - sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, diff --git a/packages/migration-legacy/src/sessions/index.ts b/packages/migration-legacy/src/sessions/index.ts index e1c6d0a98b1..130cfdc1870 100644 --- a/packages/migration-legacy/src/sessions/index.ts +++ b/packages/migration-legacy/src/sessions/index.ts @@ -126,7 +126,6 @@ export async function migrateSessionsStep( let migrated = 0; let alreadyMigrated = 0; - let repaired = 0; let processedCount = 0; for (const c of candidates) { const result = await migrateOneSession({ @@ -156,7 +155,7 @@ export async function migrateSessionsStep( reason: `session migrated but index append failed: ${String(error)}`, }); } - } else if (result.outcome === 'already-migrated' || result.outcome === 'repaired') { + } else if (result.outcome === 'already-migrated') { // The session dir exists from a prior run, but that run may have crashed // before appending the index entry. `ensureSessionIndexEntry` is // idempotent — it adds the entry only when absent — so a rerun @@ -167,8 +166,7 @@ export async function migrateSessionsStep( sessionDir: result.targetDir, workDir: c.workdirPath, }); - if (result.outcome === 'repaired') repaired++; - else alreadyMigrated++; + alreadyMigrated++; } catch (error) { // The index entry is genuinely missing and could not be added — the // session stays unreachable by id, so record it as failed. @@ -204,7 +202,6 @@ export async function migrateSessionsStep( sessionsAttempted: candidates.length, sessionsMigrated: migrated, sessionsAlreadyMigrated: alreadyMigrated, - sessionsRepaired: repaired, sessionsSkippedPlaceholder, sessionsSkippedEmpty, sessionsSkippedMalformed, @@ -287,7 +284,6 @@ function emptySummary(): SessionsSummary { sessionsAttempted: 0, sessionsMigrated: 0, sessionsAlreadyMigrated: 0, - sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, diff --git a/packages/migration-legacy/src/sessions/migrate-one.ts b/packages/migration-legacy/src/sessions/migrate-one.ts index 0cd621c6d4a..65d02829608 100644 --- a/packages/migration-legacy/src/sessions/migrate-one.ts +++ b/packages/migration-legacy/src/sessions/migrate-one.ts @@ -17,14 +17,11 @@ import { readMergedSessionState, type LegacySessionRef } from './source.js'; import { writeMainAgentWire } from './wire-writer.js'; import { writeSessionState } from './state-writer.js'; import { extractToolCallDisplays } from './tool-call-display.js'; -import { repairImportedSessionWire } from './repair-imported.js'; import { buildSubagentTaskRecords, migrateLegacySubagents } from './subagents.js'; -import { IMPORT_FORMAT_VERSION } from './turn-structure.js'; export type MigrateOneResult = | { readonly outcome: 'migrated'; readonly targetDir: string } | { readonly outcome: 'already-migrated'; readonly targetDir: string } - | { readonly outcome: 'repaired'; readonly targetDir: string } | { readonly outcome: 'conflict'; readonly targetDir: string } | { readonly outcome: 'empty' } | { readonly outcome: 'failed'; readonly reason: string }; @@ -41,23 +38,9 @@ export async function migrateOneSession(input: MigrateOneInput): Promise= IMPORT_FORMAT_VERSION) { - return { outcome: 'already-migrated', targetDir }; - } - const repaired = await repairImportedSessionWire(targetDir).catch(() => false); - if (repaired) return { outcome: 'repaired', targetDir }; - return { - outcome: 'failed', - reason: - 'imported session needs repair but its wire could not be repaired (missing, corrupt, or unwritable)', - }; + return { outcome: 'already-migrated', targetDir }; } // A real, unrelated kimi-code session occupies the path — a true conflict. if (cls === 'foreign') { @@ -231,19 +214,6 @@ async function applyOriginalMtime(targetDir: string, createdAtMs: number): Promi } } -async function readImportFormatVersion(targetDir: string): Promise { - try { - const parsed: unknown = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); - if (typeof parsed !== 'object' || parsed === null) return 0; - const custom = (parsed as { custom?: unknown }).custom; - if (typeof custom !== 'object' || custom === null) return 0; - const version = (custom as Record)['import_format_version']; - return typeof version === 'number' ? version : 0; - } catch { - return 0; - } -} - type ExistingTarget = 'imported' | 'foreign' | 'debris'; /** diff --git a/packages/migration-legacy/src/sessions/repair-imported.ts b/packages/migration-legacy/src/sessions/repair-imported.ts deleted file mode 100644 index 5340c7bf22a..00000000000 --- a/packages/migration-legacy/src/sessions/repair-imported.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { readFile, readdir, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { readTodoItems, type TodoItem } from '@moonshot-ai/agent-core-v2/features/todo/todoItem'; - -import { targetSessionsDir } from '../paths.js'; -import { readMergedSessionState } from './source.js'; -import { buildSubagentTaskRecords, migrateLegacySubagents } from './subagents.js'; -import { - IMPORT_FORMAT_VERSION, - buildTurnRecords, - splitIntoTurns, - type TurnMessage, - type WireRecord, -} from './turn-structure.js'; -import { insertSubagentTaskRecords } from './wire-writer.js'; - -/** - * In-place repair for sessions imported by an earlier migrator that lacks data - * the current migrator writes (turn-structure records, imported todo list). - * Only the imported prefix is rewritten; live records the user appended after - * the import are preserved verbatim. - * - * Returns `true` when anything changed. Returns `false` when there is nothing - * to repair (already current, or the target is unreadable/corrupt), and - * leaves every file untouched. - */ -export async function repairImportedSessionWire(targetDir: string): Promise { - const statePath = join(targetDir, 'state.json'); - let meta: Record | undefined; - try { - const parsed: unknown = JSON.parse(await readFile(statePath, 'utf-8')); - if (typeof parsed === 'object' && parsed !== null) meta = parsed as Record; - } catch { - return false; - } - if (meta === undefined) return false; - - const wirePath = join(targetDir, 'agents', 'main', 'wire.jsonl'); - let text: string; - try { - text = await readFile(wirePath, 'utf-8'); - } catch { - return false; - } - const records = parseWireRecords(text); - if (records === undefined) return false; - - let index = 0; - let metadata: WireRecord | undefined; - if (records[0]?.type === 'metadata') { - metadata = records[0]; - index = 1; - } - const createdAt = metadata?.['created_at']; - const time = typeof createdAt === 'number' ? createdAt : Date.now(); - - const hasTurnStructure = records - .slice(index, firstIndexOfType(records, index, 'context.append_message')) - .some((record) => record.type === 'turn.prompt'); - - let prefix: WireRecord[]; - if (hasTurnStructure) { - // Consume the imported turn groups so the boundary to live history is found. - const end = consumeImportedTurnGroups(records, index); - prefix = records.slice(index, end); - index = end; - } else { - // The imported prefix is the leading run of context.append_message records - // written by the old migrator; rebuild it with turn structure inserted. - const importedMessages: TurnMessage[] = []; - while (index < records.length && records[index]!.type === 'context.append_message') { - const message = records[index]!['message']; - if (typeof message !== 'object' || message === null) return false; - importedMessages.push(message as TurnMessage); - index += 1; - } - if (importedMessages.length === 0) return false; - prefix = buildTurnRecords(splitIntoTurns(importedMessages), { agentId: 'main', time }); - } - const liveSuffix = records.slice(index); - - let changed = !hasTurnStructure; - - const hasTodoRecord = records.some( - (record) => record.type === 'tools.update_store' && record['key'] === 'todo', - ); - const todoItems = hasTodoRecord ? [] : await readSourceTodos(meta); - if (todoItems.length > 0) { - prefix = [ - ...prefix, - { type: 'tools.update_store', agentId: 'main', key: 'todo', value: todoItems, time }, - ]; - changed = true; - } - - let metaChanged = false; - const sourceDir = readSourceDir(meta); - if (sourceDir !== undefined) { - const subagents = await migrateLegacySubagents(sourceDir, targetDir); - const missingTasks = subagents.filter( - (info) => - !records.some( - (record) => - record.type === 'task.started' && - (record['info'] as { agentId?: string } | undefined)?.agentId === info.agentId, - ), - ); - if (missingTasks.length > 0) { - prefix = insertSubagentTaskRecords(prefix, missingTasks.map(buildSubagentTaskRecords)); - changed = true; - } - if (ensureSubagentRegistrations(meta, subagents, targetDir)) metaChanged = true; - } - - if (ensureMetaFields(meta, records, prefix)) metaChanged = true; - if (!changed && !metaChanged) return false; - - if (changed) { - const rebuilt: WireRecord[] = [ - ...(metadata === undefined ? [] : [metadata]), - ...prefix, - ...liveSuffix, - ]; - await writeFile( - wirePath, - rebuilt.map((record) => JSON.stringify(record)).join('\n') + '\n', - 'utf-8', - ); - } - if (metaChanged) { - await writeFile(statePath, JSON.stringify(meta, null, 2), 'utf-8'); - } - return true; -} - -function parseWireRecords(text: string): WireRecord[] | undefined { - const records: WireRecord[] = []; - for (const rawLine of text.split('\n')) { - const line = rawLine.trim(); - if (line === '') continue; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - // A corrupt line means we cannot safely re-emit the file — leave it alone. - return undefined; - } - if ( - typeof parsed !== 'object' || - parsed === null || - typeof (parsed as { type?: unknown }).type !== 'string' - ) { - return undefined; - } - records.push(parsed as WireRecord); - } - return records; -} - -function firstIndexOfType( - records: readonly WireRecord[], - from: number, - type: string, -): number { - const found = records.findIndex((record, i) => i >= from && record.type === type); - return found === -1 ? records.length : found; -} - -function consumeImportedTurnGroups(records: readonly WireRecord[], from: number): number { - let index = from; - while (index < records.length && records[index]!.type === 'turn.prompt') { - index += 1; - while (index < records.length && records[index]!.type === 'context.append_message') { - index += 1; - } - if (index < records.length && records[index]!.type === 'turn.ended') index += 1; - } - return index; -} - -async function readSourceTodos(meta: Record): Promise { - const sourceDir = readSourceDir(meta); - if (sourceDir === undefined) return []; - const oldState = await readMergedSessionState(sourceDir); - return readTodoItems(oldState.todos); -} - -function readSourceDir(meta: Record): string | undefined { - const custom = meta['custom']; - if (typeof custom !== 'object' || custom === null) return undefined; - const sourcePath = (custom as Record)['kimi_cli_source_path']; - return typeof sourcePath === 'string' && sourcePath.length > 0 ? sourcePath : undefined; -} - -// Register migrated subagents in meta.agents so the session roster exposes -// their transcripts. Existing entries are never overwritten. -function ensureSubagentRegistrations( - meta: Record, - subagents: readonly { readonly agentId: string }[], - targetDir: string, -): boolean { - if (subagents.length === 0) return false; - const agents = - typeof meta['agents'] === 'object' && meta['agents'] !== null - ? (meta['agents'] as Record) - : undefined; - const nextAgents: Record = { ...agents }; - let changed = false; - for (const info of subagents) { - if (nextAgents[info.agentId] !== undefined) continue; - nextAgents[info.agentId] = { - homedir: join(targetDir, 'agents', info.agentId), - type: 'sub', - parentAgentId: 'main', - labels: { parentAgentId: 'main' }, - }; - changed = true; - } - if (changed) meta['agents'] = nextAgents; - return changed; -} - -// Stamp the current import format version and backfill lastTurnReason (the -// session-outcome mirror clears a persisted reason when the wire has no ended -// turn). Returns whether meta was modified. -function ensureMetaFields( - meta: Record, - records: readonly WireRecord[], - prefix: readonly WireRecord[], -): boolean { - let changed = false; - const custom = meta['custom']; - if (typeof custom === 'object' && custom !== null) { - const record = custom as Record; - if (record['import_format_version'] !== IMPORT_FORMAT_VERSION) { - record['import_format_version'] = IMPORT_FORMAT_VERSION; - changed = true; - } - } - if ( - meta['lastTurnReason'] === undefined && - [...records, ...prefix].some((record) => record.type === 'turn.ended') - ) { - meta['lastTurnReason'] = 'completed'; - changed = true; - } - return changed; -} - -/** - * Count previously imported sessions under the target home whose import format - * predates the current migrator (see IMPORT_FORMAT_VERSION). Drives - * repair-aware detection: a completed migration marker must not permanently - * hide sessions an old migrator left unrepaired. One small state.json read - * per session, cheap enough to run on every startup. - */ -export async function countImportedSessionsNeedingRepair(targetHome: string): Promise { - const sessionsRoot = targetSessionsDir(targetHome); - let bucketNames: string[]; - try { - bucketNames = await readdir(sessionsRoot); - } catch { - return 0; - } - let count = 0; - for (const bucketName of bucketNames) { - let sessionNames: string[]; - try { - sessionNames = await readdir(join(sessionsRoot, bucketName)); - } catch { - continue; - } - for (const sessionName of sessionNames) { - if (await importedSessionNeedsRepair(join(sessionsRoot, bucketName, sessionName))) { - count++; - } - } - } - return count; -} - -async function importedSessionNeedsRepair(sessionDir: string): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf-8')); - } catch { - return false; - } - if (typeof parsed !== 'object' || parsed === null) return false; - const custom = (parsed as { custom?: unknown }).custom; - if (typeof custom !== 'object' || custom === null) return false; - const record = custom as Record; - if (record['imported_from_kimi_cli'] !== true) return false; - const version = record['import_format_version']; - return typeof version !== 'number' || version < IMPORT_FORMAT_VERSION; -} diff --git a/packages/migration-legacy/src/sessions/state-writer.ts b/packages/migration-legacy/src/sessions/state-writer.ts index d25dfa3d2f6..a7626b18eb1 100644 --- a/packages/migration-legacy/src/sessions/state-writer.ts +++ b/packages/migration-legacy/src/sessions/state-writer.ts @@ -7,7 +7,6 @@ import { } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; import type { OldSessionState } from '../kimi-cli-schema.js'; -import { IMPORT_FORMAT_VERSION } from './turn-structure.js'; export interface StateWriteInput { readonly oldState: Partial; @@ -88,7 +87,6 @@ export async function writeSessionState(sessionDir: string, input: StateWriteInp }, custom: { imported_from_kimi_cli: true, - import_format_version: IMPORT_FORMAT_VERSION, kimi_cli_source_path: input.sourcePath, kimi_cli_session_id: input.oldSessionUuid, kimi_cli_wire_protocol: input.wireProtocolFromOld, diff --git a/packages/migration-legacy/src/sessions/turn-structure.ts b/packages/migration-legacy/src/sessions/turn-structure.ts index 2b5e398e573..b45c3973222 100644 --- a/packages/migration-legacy/src/sessions/turn-structure.ts +++ b/packages/migration-legacy/src/sessions/turn-structure.ts @@ -1,15 +1,5 @@ import type { NormalizedContentPart } from './content-part.js'; -// Format version of an imported session, stamped into state.json -// `custom.import_format_version`. Absent means an old message-only import -// (treated as 0). Bump when the migrator starts writing new wire/meta data so -// detection can offer an in-place repair run instead of letting a completed -// marker hide the missing data forever. -// 1 = turn-structure records + token_counting.measured + lastTurnReason + -// imported todo list -// 2 = subagent wires + subagent task records + plans copy + device_id copy -export const IMPORT_FORMAT_VERSION = 2; - export interface TurnMessage { readonly role: string; readonly content?: readonly NormalizedContentPart[]; diff --git a/packages/migration-legacy/src/types.ts b/packages/migration-legacy/src/types.ts index 88f68d62d36..6b7c0417659 100644 --- a/packages/migration-legacy/src/types.ts +++ b/packages/migration-legacy/src/types.ts @@ -21,13 +21,6 @@ export interface MigrationPlan { readonly detectedPlugins: readonly string[]; readonly detectedMcpOauthServers: readonly string[]; readonly totalSessions: number; // sum across workdirs (real, post-classify) - /** - * Previously imported sessions under the target home whose wire still lacks - * turn-structure records (an old migrator wrote message-only imports). - * Filled by callers via `countImportedSessionsNeedingRepair`; a value > 0 - * means a completed marker must not suppress a repair run. - */ - readonly sessionsNeedingRepair?: number; /** * Session storage that detection could see but could not safely inspect. * Optional for callers that persisted or constructed an older plan shape. @@ -143,11 +136,6 @@ export interface SessionsSummary { readonly sessionsMigrated: number; /** Sessions already imported by a previous run (idempotent re-run). */ readonly sessionsAlreadyMigrated: number; - /** - * Previously imported sessions whose wire was repaired in place on this run - * (turn-structure records inserted into an old message-only import). - */ - readonly sessionsRepaired: number; readonly sessionsSkippedPlaceholder: number; readonly sessionsSkippedEmpty: number; readonly sessionsSkippedMalformed: number; diff --git a/packages/migration-legacy/test/report.test.ts b/packages/migration-legacy/test/report.test.ts index 57263603dd7..c4819318d93 100644 --- a/packages/migration-legacy/test/report.test.ts +++ b/packages/migration-legacy/test/report.test.ts @@ -49,7 +49,6 @@ describe('writeReport', () => { sessionsAttempted: 0, sessionsMigrated: 0, sessionsAlreadyMigrated: 0, - sessionsRepaired: 0, sessionsSkippedPlaceholder: 0, sessionsSkippedEmpty: 0, sessionsSkippedMalformed: 0, diff --git a/packages/migration-legacy/test/sessions/__snapshots__/fixtures.snapshot.test.ts.snap b/packages/migration-legacy/test/sessions/__snapshots__/fixtures.snapshot.test.ts.snap index baee1513579..9f752288a24 100644 --- a/packages/migration-legacy/test/sessions/__snapshots__/fixtures.snapshot.test.ts.snap +++ b/packages/migration-legacy/test/sessions/__snapshots__/fixtures.snapshot.test.ts.snap @@ -24,7 +24,6 @@ exports[`migration snapshot: archived > migration succeeds and matches snapshot }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "archived", "kimi_cli_wire_protocol": "1.8", @@ -72,7 +71,6 @@ exports[`migration snapshot: large-100msgs > migration succeeds and matches snap }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "large-100msgs", "kimi_cli_wire_protocol": "1.4", @@ -179,7 +177,6 @@ exports[`migration snapshot: legacy-protocol-1.3 > migration succeeds and matche }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "legacy-protocol-1.3", "kimi_cli_wire_protocol": "1.3", @@ -227,7 +224,6 @@ exports[`migration snapshot: recent-protocol-1.10 > migration succeeds and match }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "recent-protocol-1.10", "kimi_cli_wire_protocol": "1.10", @@ -278,7 +274,6 @@ exports[`migration snapshot: tiny-hello-world > migration succeeds and matches s }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "tiny-hello-world", "kimi_cli_wire_protocol": "1.10", @@ -323,7 +318,6 @@ exports[`migration snapshot: with-image > migration succeeds and matches snapsho }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "with-image", "kimi_cli_wire_protocol": "1.7", @@ -368,7 +362,6 @@ exports[`migration snapshot: with-subagent-collapsed > migration succeeds and ma }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "with-subagent-collapsed", "kimi_cli_wire_protocol": "1.6", @@ -416,7 +409,6 @@ exports[`migration snapshot: with-thinking > migration succeeds and matches snap }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "with-thinking", "kimi_cli_wire_protocol": "1.9", @@ -465,7 +457,6 @@ exports[`migration snapshot: with-tool-calls > migration succeeds and matches sn }, "custom": { "imported_from_kimi_cli": true, - "import_format_version": 2, "kimi_cli_source_path": "", "kimi_cli_session_id": "with-tool-calls", "kimi_cli_wire_protocol": "1.8", diff --git a/packages/migration-legacy/test/sessions/migrate-one.test.ts b/packages/migration-legacy/test/sessions/migrate-one.test.ts index 189438d684d..e4cee02ead9 100644 --- a/packages/migration-legacy/test/sessions/migrate-one.test.ts +++ b/packages/migration-legacy/test/sessions/migrate-one.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { migrateOneSession, type MigrateOneResult } from '../../src/sessions/migrate-one.js'; -import { countImportedSessionsNeedingRepair } from '../../src/sessions/repair-imported.js'; import { computeWorkdirBucket } from '../../src/sessions/workdir-bucket.js'; import { targetSessionsDir } from '../../src/paths.js'; @@ -318,241 +317,38 @@ describe('migrateOneSession (tiny-hello-world fixture)', () => { }); }); -describe('migrateOneSession repair of message-only imports', () => { - const workdirPath = '/Users/me/proj'; - - async function seedImportedTarget( - wireLines: string[], - stateExtra: Record = {}, - ): Promise { +describe('migrateOneSession with a pre-existing old-format import', () => { + it('leaves an old message-only import untouched as already-migrated', async () => { + const workdirPath = '/Users/me/proj'; const targetDir = join( targetSessionsDir(targetHome), computeWorkdirBucket(workdirPath), - 'ses_repair-uuid', + 'ses_old-import-uuid', ); await mkdir(join(targetDir, 'agents', 'main'), { recursive: true }); + const wireLines = [ + '{"type":"metadata","protocol_version":"1.0","created_at":1700000000000}', + '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"old question"}],"toolCalls":[]}}', + ]; await writeFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), wireLines.join('\n') + '\n'); await writeFile( join(targetDir, 'state.json'), JSON.stringify({ - id: 'ses_repair-uuid', + id: 'ses_old-import-uuid', title: 'old import', - custom: { imported_from_kimi_cli: true, kimi_cli_session_id: 'repair-uuid' }, - ...stateExtra, + custom: { imported_from_kimi_cli: true, kimi_cli_session_id: 'old-import-uuid' }, }), ); - return targetDir; - } - const importedWire = [ - '{"type":"metadata","protocol_version":"1.0","created_at":1700000000000}', - '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"old question"}],"toolCalls":[]}}', - '{"type":"context.append_message","message":{"role":"assistant","content":[{"type":"text","text":"old answer"}],"toolCalls":[]}}', - ]; - - function runMigrate() { - return migrateOneSession({ - source: { uuid: 'repair-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(FIXTURES, 'tiny-hello-world', 'context.jsonl') }, + const result = await migrateOneSession({ + source: { uuid: 'old-import-uuid', sessionDir: join(FIXTURES, 'tiny-hello-world'), contextPath: join(FIXTURES, 'tiny-hello-world', 'context.jsonl') }, workdirPath, targetHome, }); - } - - it('inserts turn structure into a message-only imported wire, once', async () => { - const targetDir = await seedImportedTarget(importedWire); - - const first = await runMigrate(); - expect(first.outcome).toBe('repaired'); - - const records = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) - .split('\n') - .filter((l) => l.length > 0) - .map((l) => JSON.parse(l) as { type: string }); - expect(records.map((r) => r.type)).toEqual([ - 'metadata', - 'turn.prompt', - 'context.append_message', - 'context.append_message', - 'turn.ended', - ]); - expect(records[1]).toMatchObject({ - agentId: 'main', - origin: { kind: 'user' }, - input: [{ type: 'text', text: 'old question' }], - time: 1700000000000, - }); - expect(records[4]).toMatchObject({ agentId: 'main', turnId: 0, reason: 'completed' }); - - const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); - expect(state.lastTurnReason).toBe('completed'); - expect(state.custom.import_format_version).toBe(2); - - const second = await runMigrate(); - expect(second.outcome).toBe('already-migrated'); - }); - - it('imports the legacy todo list as a tools.update_store record', async () => { - const sourceDir = join(targetHome, 'src-with-todos'); - await mkdir(sourceDir, { recursive: true }); - await writeFile( - join(sourceDir, 'state.json'), - JSON.stringify({ - todos: [ - { title: '创建 f1.txt', status: 'done' }, - { title: '创建 f2.txt', status: 'in_progress' }, - { title: 'bogus', status: 'weird' }, - ], - }), - ); - const liveSuffix = [ - '{"type":"prompt.completed","agentId":"main","promptId":"msg_live1","time":1800000000002}', - ]; - const targetDir = await seedImportedTarget([...importedWire, ...liveSuffix], { - custom: { - imported_from_kimi_cli: true, - kimi_cli_session_id: 'repair-uuid', - kimi_cli_source_path: sourceDir, - }, - }); - - const first = await runMigrate(); - expect(first.outcome).toBe('repaired'); - - const lines = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) - .split('\n') - .filter((l) => l.length > 0); - expect(lines.map((l) => (JSON.parse(l) as { type: string }).type)).toEqual([ - 'metadata', - 'turn.prompt', - 'context.append_message', - 'context.append_message', - 'turn.ended', - 'tools.update_store', - 'prompt.completed', - ]); - const todoRecord = JSON.parse(lines[5]!); - // Invalid entries are filtered out; order is preserved. - expect(todoRecord.value).toEqual([ - { title: '创建 f1.txt', status: 'done' }, - { title: '创建 f2.txt', status: 'in_progress' }, - ]); - expect(todoRecord.time).toBe(1700000000000); - expect(lines[6]).toBe(liveSuffix[0]); - - const second = await runMigrate(); - expect(second.outcome).toBe('already-migrated'); - }); - - it('preserves a live suffix verbatim while repairing the imported prefix', async () => { - const liveSuffix = [ - '{"type":"turn.prompt","agentId":"main","input":[{"type":"text","text":"new question"}],"origin":{"kind":"user"},"time":1800000000000}', - '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"new question"}],"toolCalls":[],"origin":{"kind":"user"},"id":"msg_live1"}}', - '{"type":"turn.ended","agentId":"main","turnId":0,"reason":"completed","time":1800000000001}', - ]; - const targetDir = await seedImportedTarget( - [...importedWire, ...liveSuffix], - { lastTurnReason: 'completed' }, - ); - - const first = await runMigrate(); - expect(first.outcome).toBe('repaired'); - - const lines = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) - .split('\n') - .filter((l) => l.length > 0); - expect(lines.map((l) => (JSON.parse(l) as { type: string }).type)).toEqual([ - 'metadata', - 'turn.prompt', - 'context.append_message', - 'context.append_message', - 'turn.ended', - 'turn.prompt', - 'context.append_message', - 'turn.ended', - ]); - expect(lines.slice(5)).toEqual(liveSuffix); - - const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); - expect(state.lastTurnReason).toBe('completed'); - }); - - it('reports failed when a session needing repair has an unrepairable wire', async () => { - await seedImportedTarget(['{"type":"metadata","protocol_version":"1.0","created_at":1}', '{broken']); - const result = await runMigrate(); - expect(result.outcome).toBe('failed'); - if (result.outcome === 'failed') { - expect(result.reason).toMatch(/repair/i); - } - }); - - it('stays already-migrated for an import at the current format version', async () => { - const targetDir = join( - targetSessionsDir(targetHome), - computeWorkdirBucket(workdirPath), - 'ses_repair-uuid', - ); - await mkdir(join(targetDir, 'agents', 'main'), { recursive: true }); - await writeFile( - join(targetDir, 'state.json'), - JSON.stringify({ - id: 'ses_repair-uuid', - title: 'current import', - custom: { - imported_from_kimi_cli: true, - kimi_cli_session_id: 'repair-uuid', - import_format_version: 2, - }, - }), - ); - const result = await runMigrate(); expect(result.outcome).toBe('already-migrated'); - }); -}); - -describe('countImportedSessionsNeedingRepair', () => { - it('counts imported sessions whose import format predates the current migrator', async () => { - const workdirPath = '/Users/me/proj'; - const bucket = join(targetSessionsDir(targetHome), computeWorkdirBucket(workdirPath)); - - const needsRepair = join(bucket, 'ses_old-import'); - await mkdir(join(needsRepair, 'agents', 'main'), { recursive: true }); - await writeFile( - join(needsRepair, 'agents', 'main', 'wire.jsonl'), - '{"type":"metadata","protocol_version":"1.0","created_at":1}\n' + - '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"x"}],"toolCalls":[]}}\n', - ); - await writeFile( - join(needsRepair, 'state.json'), - JSON.stringify({ custom: { imported_from_kimi_cli: true } }), - ); - - const current = join(bucket, 'ses_current-import'); - await mkdir(join(current, 'agents', 'main'), { recursive: true }); - await writeFile( - join(current, 'agents', 'main', 'wire.jsonl'), - '{"type":"metadata","protocol_version":"1.0","created_at":1}\n' + - '{"type":"turn.prompt","agentId":"main","input":[],"origin":{"kind":"user"},"time":1}\n' + - '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"x"}],"toolCalls":[]}}\n', - ); - await writeFile( - join(current, 'state.json'), - JSON.stringify({ custom: { imported_from_kimi_cli: true, import_format_version: 2 } }), - ); - - const native = join(bucket, 'ses_native'); - await mkdir(join(native, 'agents', 'main'), { recursive: true }); - await writeFile( - join(native, 'agents', 'main', 'wire.jsonl'), - '{"type":"metadata","protocol_version":"1.5","created_at":1}\n' + - '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"x"}],"toolCalls":[]}}\n', - ); - await writeFile(join(native, 'state.json'), JSON.stringify({ title: 'real session' })); - expect(await countImportedSessionsNeedingRepair(targetHome)).toBe(1); - }); - - it('returns 0 for a missing sessions root', async () => { - expect(await countImportedSessionsNeedingRepair(join(targetHome, 'nope'))).toBe(0); + const wire = await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); + expect(wire).toBe(wireLines.join('\n') + '\n'); }); }); @@ -595,7 +391,7 @@ describe('migrateOneSession todo list migration', () => { ], }); const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); - expect(state.custom.import_format_version).toBe(2); + expect(state.custom.imported_from_kimi_cli).toBe(true); }); }); @@ -722,71 +518,4 @@ describe('migrateOneSession subagent migration', () => { expect(second.outcome).toBe('already-migrated'); }); - it('repairs a message-only import by adding subagent wires and task records', async () => { - const srcDir = await seedSourceWithSubagent(); - const targetDir = join( - targetSessionsDir(targetHome), - computeWorkdirBucket(workdirPath), - 'ses_repair-sub-uuid', - ); - await mkdir(join(targetDir, 'agents', 'main'), { recursive: true }); - await writeFile( - join(targetDir, 'agents', 'main', 'wire.jsonl'), - [ - '{"type":"metadata","protocol_version":"1.0","created_at":1700000000000}', - '{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"run a subagent"}],"toolCalls":[]}}', - '{"type":"context.append_message","message":{"role":"assistant","content":[],"toolCalls":[{"type":"function","id":"tool_X","function":{"name":"Agent","arguments":"{}"}}]}}', - '{"type":"context.append_message","message":{"role":"tool","content":[{"type":"text","text":"56088"}],"toolCalls":[],"toolCallId":"tool_X"}}', - ].join('\n') + '\n', - ); - await writeFile( - join(targetDir, 'state.json'), - JSON.stringify({ - id: 'ses_repair-sub-uuid', - title: 'old import', - custom: { - imported_from_kimi_cli: true, - kimi_cli_session_id: 'repair-sub-uuid', - kimi_cli_source_path: srcDir, - }, - }), - ); - - const result = await migrateOneSession({ - source: { uuid: 'repair-sub-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, - workdirPath, - targetHome, - }); - expect(result.outcome).toBe('repaired'); - - const subWire = await readFile(join(targetDir, 'agents', 'sub1', 'wire.jsonl'), 'utf-8'); - expect(subWire).toContain('"agentId":"sub1"'); - - const mainWire = (await readFile(join(targetDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')) - .split('\n') - .filter((l) => l.length > 0) - .map((l) => JSON.parse(l) as { type: string }); - expect(mainWire.map((r) => r.type)).toEqual([ - 'metadata', - 'turn.prompt', - 'context.append_message', - 'task.started', - 'context.append_message', - 'context.append_message', - 'task.terminated', - 'turn.ended', - ]); - expect(mainWire[3]).toMatchObject({ info: { taskId: 'sub1', parentToolCallId: 'tool_X' } }); - - const state = JSON.parse(await readFile(join(targetDir, 'state.json'), 'utf-8')); - expect(state.agents.sub1).toBeDefined(); - expect(state.custom.import_format_version).toBe(2); - - const second = await migrateOneSession({ - source: { uuid: 'repair-sub-uuid', sessionDir: srcDir, contextPath: join(srcDir, 'context.jsonl') }, - workdirPath, - targetHome, - }); - expect(second.outcome).toBe('already-migrated'); - }); }); diff --git a/packages/node-sdk/CHANGELOG.md b/packages/node-sdk/CHANGELOG.md index d029d165b13..c2bde4d1012 100644 --- a/packages/node-sdk/CHANGELOG.md +++ b/packages/node-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/kimi-code-sdk +## 0.20.0 + +### Minor Changes + +- [#3453](https://github.com/MoonshotAI/kimi-code/pull/3453) [`411572e`](https://github.com/MoonshotAI/kimi-code/commit/411572e166edee8581ba9c5a7f1bbf6c8b405606) Thanks [@Grapedge](https://github.com/Grapedge)! - Add `suggestFiles(workDir, { query, limit })` to `KimiHarness`: session-less fuzzy file suggestions from the agent-core-v2 workspace fs service, `undefined` on the v1 engine. + ## 0.19.2 ### Patch Changes diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 54c5d1dada7..5b136d341a1 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code-sdk", - "version": "0.19.2", + "version": "0.20.0", "private": true, "description": "TypeScript SDK for the Kimi Code Agent", "license": "MIT", diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index d6fa71236ce..208c6ceeac4 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -235,6 +235,7 @@ import { resolveLoggingConfig, resolvePrintBackgroundMode, summarizeSkill, + towerEnterFailureMessage, type IAgentScopeHandle, type IDisposable, type ISessionScopeHandle, @@ -539,16 +540,20 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * Forward engine telemetry to the host-supplied client. Without this the * client only served `KimiHarness`-level events and every engine-side event * (`track2` facts from agent/session scopes) was dropped on the v2 route. - * The `ITelemetryAppender` shape is a structural superset of the v1 - * `TelemetryClient`, so the client installs directly. The `telemetry` - * config section gates engine events the same way the v2 print runner - * gates them; the host keeps owning the client's lifecycle (flush / - * shutdown stay with the host, matching the v1 core's arrangement). + * The v1 `TelemetryClient` is wrapped into the engine appender record shape + * (event + ambient context + final properties). The `telemetry` config + * section gates engine events the same way the v2 print runner gates them; + * the host keeps owning the client's lifecycle (flush / shutdown stay with + * the host, matching the v1 core's arrangement). */ private installEngineTelemetry(client: TelemetryClient | undefined): void { if (client === undefined) return; const telemetry = this.app.accessor.get(ITelemetryService); - telemetry.setAppender(client); + telemetry.addAppender({ + track: (record) => { + client.track(record.event, record.properties); + }, + }); void this.configReady.then(() => { telemetry.setEnabled(this.engineAccessor.get(IConfigService).get('telemetry') !== false); }); @@ -2180,11 +2185,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { const agent = await this.agentScope(input.sessionId); const tower = agent.accessor.get(IAgentTowerService); if (input.enabled) { - await tower.enter(input.base); - if (!tower.isActive) { + const result = await tower.enter(input.base); + if (!result.entered) { throw new V2Error2( V2ErrorCodes.SESSION_TOWER_MODE_INVALID, - 'tower mode could not be enabled — another live session owns the workspace tower', + towerEnterFailureMessage(result), ); } } else { diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index f2b94cbafce..8e0b05b3f24 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -1094,8 +1094,8 @@ key = "${titleOAuthRef.key}" }; await client.setTowerMode({ sessionId: 'ses_tower', enabled: true }); - // The tower feature is flag-gated engine-side, so enter() may be a - // no-op; the wire must always mirror the engine truth. + // A refused enter() rejects with a typed reason, so a resolved call + // means the engine activated tower mode; the wire mirrors it. expect((await client.getStatus({ sessionId: 'ses_tower' })).towerMode).toBe( mainTower().isActive, ); @@ -1114,6 +1114,7 @@ key = "${titleOAuthRef.key}" }); it('rejects setTowerMode when the tower feature is unavailable', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOWER', '0'); vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); @@ -1124,7 +1125,10 @@ key = "${titleOAuthRef.key}" await client.createSession({ id: 'ses_tower_off', workDir }); await expect(client.setTowerMode({ sessionId: 'ses_tower_off', enabled: true })) - .rejects.toMatchObject({ code: 'session.tower_mode_invalid' }); + .rejects.toMatchObject({ + code: 'session.tower_mode_invalid', + message: expect.stringContaining('the tower experiment is disabled'), + }); expect((await client.getStatus({ sessionId: 'ses_tower_off' })).towerMode).toBe(false); await client.setTowerMode({ sessionId: 'ses_tower_off', enabled: false }); diff --git a/packages/transcript/docs/sdk.md b/packages/transcript/docs/sdk.md deleted file mode 100644 index bc43598d431..00000000000 --- a/packages/transcript/docs/sdk.md +++ /dev/null @@ -1,365 +0,0 @@ -# Transcript SDK - -本文档描述 transcript 对外契约的**当前实现**,读者为 transcript 通道的消费方(kimi-code-app、kimi-inspect、外部 REST/WS 客户端)与 transcript 包的维护者。契约的权威定义是 `src/contract/schema.ts` 的 zod schema;本文档是其可读形式,两者冲突时以 schema 为准并修正本文档。契约变更必须附带 migration 文档(见第八节)。 - -## 一、定位与分层 - -transcript 是 session 对话时间线的读取通道,同一份数据有两种喂法: - -- **live**:kap-server 订阅 core 的 observable 事件(IEventBus),由 projector 翻译成 ops 写入内存 store; -- **cold**:从 `wire.jsonl` 的 durable 记录两层 fold 重建(`history/groupTurns.ts` 负责 context 消息 → turn 树,`history/foldFacts.ts` 负责非 context 记录 → 实体与 meta)。 - -wire.jsonl 是历史的唯一真相源;live store 是纯内存态,随 session 消亡。cold 重建存在已声明的字段缺口(见 2.8 已知限制)。 - -```text -TranscriptStore(per session) -└── agents: Map + roster: AgentDescriptor[] - └── AgentState - ├── items: (Turn | Marker | TaskRef)[] 时间线;Turn 内嵌 steps[],Step 内嵌 frames[] - ├── tasks / interactions / attachments / todos / prompts 全局实体 - ├── meta(goal / modes / activity / agent) - └── hasMoreOlder -``` - -ID 规范:turn `t{N}`(ordinal 从 0 起,与引擎一致)、step `t{N}.{M}`、文本/thinking frame `t{N}.{M}.f{K}`、tool frame `t{N}.{M}.{toolCallId}`。marker id 在 live 路径为 `live-m{N}`,cold 路径为 `m{N}`。 - -## 二、数据模型 - -### 2.1 AgentDescriptor - -```ts -interface AgentDescriptor { - agentId: AgentId; - type?: 'main' | 'sub' | 'independent'; - parentAgentId?: AgentId; - label?: string; - createdAt?: string; - disposedAt?: string; -} -``` - -当前写入逻辑只按 `agentId === 'main'` 区分:main 写 `'main'`,其余写 `'sub'`(`agentLifecycleService.ts:184`);`'independent'` 无写入方。btw 侧栏 agent 与 subagent 在元数据上无法区分(都注册为 `'sub'`、`parentAgentId: 'main'`,区别仅在有无 labels)。 - -### 2.2 Turn / Step - -```ts -interface Turn { - kind: 'turn'; - turnId: TurnId; - triggerPromptId?: string; - ordinal: number; - state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; - origin: TurnOrigin; // { kind: 'user'|'cron'|'task'|'hook'|'compaction'|'side'|'other', taskId?, payload? } - prompt?: string; - attachmentIds?: AttachmentId[]; - steps: Step[]; - startedAt?: string; - endedAt?: string; - usage?: Usage; // { inputTokens?, outputTokens?, cachedTokens?, cost? } - durationMs?: number; - error?: string; -} - -interface Step { - kind: 'step'; - stepId: StepId; - turnId: TurnId; - ordinal: number; - state: 'running' | 'completed' | 'interrupted' | 'failed'; - frames: Frame[]; - startedAt?: string; - endedAt?: string; - usage?: StepUsage; // { inputOther, output, inputCacheRead, inputCacheCreation } - finishReason?: string; - timing?: StepTiming; // llmFirstTokenLatencyMs / llmStreamDurationMs / llmRequestBuildMs / llmServerFirstTokenMs / llmServerDecodeMs / llmClientConsumeMs - retry?: StepRetry; // { failedAttempt, nextAttempt, maxAttempts, delayMs, errorName, errorMessage, statusCode? } - endReason?: string; - endMessage?: string; -} -``` - -实际状态机比枚举声明小:Turn 实际只有 `running → completed | failed | cancelled`(`'queued'` 无写入方);Step 实际只有 `running → completed | interrupted`(`'failed'` 无写入方)。core 的 `TurnEndReason` 有 4 值(含 `'blocked'`),投影到 transcript 时 `'blocked'` 折叠为 `'failed'`。 - -### 2.3 Frame - -```ts -type Frame = TextFrame | ThinkingFrame | ToolCallFrame | NoticeFrame; -``` - -- `TextFrame`:`{ kind: 'text', frameId, role: 'assistant'|'user', text, attachmentIds?, taskId?, promptIds?, origin? }`(user 帧的 origin 可带 skillActivations) -- `ThinkingFrame`:`{ kind: 'thinking', frameId, text }` -- `ToolCallFrame`:`{ kind: 'tool', frameId, toolCallId, name, state: 'running'|'done'|'error', view?, input?, output?, display?, error?, inputText?, progress?, taskId?, approvalId?, todoId?, agentRefs? }` -- `NoticeFrame`:`{ kind: 'notice', frameId, level: 'error'|'warning'|'info', source?, message, detail? }` - -### 2.4 Marker / TaskRef / Task / Interaction / Todo / Attachment - -```ts -interface Marker { - kind: 'marker'; - markerId: string; - marker: string; // KNOWN_MARKERS 见下 - payload?: unknown; - at?: string; -} -``` - -`KNOWN_MARKERS`:`'compaction' | 'undo' | 'clear' | 'goal' | 'plan.enter' | 'plan.exit' | 'plan.revision' | 'swarm.enter' | 'swarm.exit' | 'skill' | 'cron.fired' | 'notice' | 'hook'`(`marker` 字段类型为自由 string,KNOWN_MARKERS 是已知键清单)。 - -```ts -interface TaskRef { kind: 'taskref'; refId: string; taskId: TaskId; at?: string } - -interface Task { - taskId: TaskId; - kind: 'shell' | 'subagent' | 'tool' | 'other'; // 'tool' 无写入方 - state: 'running' | 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost'; - detached: boolean; - description?: string; - agentId?: AgentId; - outputTail: string; - startedAt?: string; - endedAt?: string; - resultSummary?: string; - error?: string; - stateReason?: string; - usage?: StepUsage; - model?: string; - thinkingEffort?: string; -} - -interface Interaction { - interactionId: InteractionId; - interactionKind: 'approval' | 'question'; - toolCallId?: string; - state: 'pending' | 'approved' | 'rejected' | 'cancelled' | 'answered' | 'dismissed'; - request?: unknown; - response?: unknown; -} -``` - -### 2.5 Prompt - -```ts -interface Prompt { - promptId: PromptId; - status: 'running' | 'queued' | 'blocked' | 'completed' | 'failed' | 'aborted'; - userMessageId?: string; - content?: unknown; - createdAt: string; - finishedAt?: string; - steeredAt?: string; -} -``` - -prompt 实体只在 live 路径写入(cold 重建不构建 prompts 列表)。`'blocked'` 表示被外部 hook 拦截(turn 未启动);steer 吸收的子 prompt 记为 `'completed'` 并带 `steeredAt`。 - -### 2.6 Meta - -```ts -interface TranscriptMeta { - goal?: { objective: string; status: 'active'|'paused'|'blocked'|'complete'; completionCriterion?; budgetUsed?; budgetLimit? }; - modes?: { plan?: { reviewPath?, version? }; swarm?: { trigger? }; tower?: {} }; - activity?: 'idle' | 'turn' | 'disposing' | 'unknown'; // 实际只写 'idle'/'turn' - agent?: AgentStatusMeta; -} - -interface AgentStatusMeta { - model?: string; - thinkingEffort?: string; - usage?: { byModel?: Record; currentTurn?: StepUsage; total?: StepUsage }; - contextTokens?: number; - maxContextTokens?: number; - contextUsage?: number; - permission?: 'manual' | 'yolo' | 'auto'; - phase?: AgentPhaseMeta; -} -``` - -`AgentPhaseMeta` 8 种 kind:`idle | running | streaming | tool_call | retrying | awaiting_approval | interrupted | ended`,由 kap-server 的 `toLegacyPhase` 从 core 的 `AgentActivityState` 映射,经 `meta.merge` 下发;cold 路径不回填。`AgentStatusMeta` 的 `permission` 与 `contextUsage` 当前无写入方(schema 声明保留)。 - -### 2.7 Snapshot - -```ts -interface AgentTranscriptSnapshot { - items: TranscriptItem[]; - tasks: Task[]; - interactions: Interaction[]; - attachments: Attachment[]; - todos: Todo[]; - prompts: Prompt[]; - meta: TranscriptMeta; - hasMoreOlder?: boolean; -} -``` - -### 2.8 已知限制(cold 重建缺口) - -- `step.retry` 不回填(retry 事件自 #3428 起已写入 wire,但 fold 尚未消费,transient by design)。 -- `step.usage / timing / finishReason`、`turn.usage`、`meta.agent.*`、`agent.phase`、prompts 列表不回填。 -- step 中断信息(`state: 'interrupted'` + `endReason` / `endMessage` / `endedAt`)自 #3428 起由 durable `turn.step.interrupted` 记录回填(context 树缺少对应 step 时会合成)。 -- 缺少 `turn.ended` 记录的 turn(进程崩溃中断)在 cold 路径一律标 `'completed'`。 -- live/cold 对同一逻辑 marker 使用不同 id 命名空间(`live-m{N}` vs `m{N}`)。 - -## 三、Operations(ops) - -所有 store 变更以 op batch 应用。op 联合(14 个成员): - -| op | payload | 语义 | -|---|---|---| -| `reset` | `{ agentId, snapshot }` | 整体替换 AgentState;server 不产生,仅客户端 store/测试使用(server 侧 reset 以专用帧存在) | -| `turn.upsert` | `{ turn: TurnHeader }` | upsert turn 头,保留已有 steps | -| `step.upsert` | `{ turnId, step: StepHeader }` | upsert step 头,保留已有 frames | -| `frame.upsert` | `{ turnId, stepId, frame }` | 整帧替换 | -| `append` | `{ target, offset, text }` | 向 text/thinking 帧或 task.outputTail 追加;幂等键 `(target, offset)`,重叠合并,gap 整批拒绝 | -| `marker.upsert` | `{ item, beforeTurn? }` | 时间线 marker | -| `taskref.upsert` | `{ item, beforeTurn? }` | 时间线 task 引用 | -| `task.upsert` | `{ task }` | task 实体 | -| `interaction.upsert` | `{ interaction }` | interaction 实体,同步维护 pendingInteractions | -| `attachment.upsert` | `{ attachment }` | attachment 实体 | -| `todo.upsert` | `{ todo }` | todo 实体 | -| `prompt.upsert` | `{ prompt }` | prompt 实体(live-only) | -| `meta.merge` | `{ meta }` | 深合并 meta,`null` 表示删除该键 | -| `items.remove` | `{ ids }` | 删除时间线条目,级联删除锚定的 interaction | - -规则: - -1. **幂等**:upsert 做字段级相等判断,无变化的 op 被丢弃、不通知订阅者;整批重放是 no-op。 -2. **序号**:server 给每批分配 per-(session, agent) 连续 `seq`,watermark = 最新已分配 seq;journal 容量 2000 批(`TRANSCRIPT_OPS_JOURNAL_CAPACITY`),随 live store 消亡。 - -## 四、WebSocket 协议 - -### 4.1 订阅 - -```json -{ "type": "subscribe_v2", "id": "sub-1", - "payload": { "session_id": "", - "transcript": { "*": "delta" }, - "transcript_since": { "main": 42 } } } -``` - -- `transcript`:`{ : grade }`,grade ∈ `off | turn | block | delta`。 -- `transcript_since`:可选,按 agent 携带已见 seq。journal 覆盖则回放 op 批;覆盖不到(或 session 冷)回退 `transcript.reset`。 -- `unsubscribe_v2`:`{ agent_ids? }`,缺省摘除整个 session 的 transcript 订阅;被摘除的 agent 恢复接收 legacy session_event。 -- grade 升级触发重发 reset;降级/同级不重发。 - -### 4.2 下发帧 - -transcript 帧包裹在 session 事件 envelope 中(外层 `seq` 是 session 事件 journal 序号,与 `payload.seq` 的 transcript op-batch 序号无关): - -```json -{ "type": "transcript.ops", "seq": 137, "epoch": "...", "volatile": true, - "session_id": "", "timestamp": "", - "payload": { "type": "transcript.ops", "agent_id": "main", - "ops": [ /* TranscriptOp[] */ ], "seq": 43 } } - -{ "type": "transcript.reset", "seq": 136, "volatile": true, "session_id": "", - "payload": { "type": "transcript.reset", "agent_id": "main", - "snapshot": { "items": [], "tasks": [], "interactions": [], - "attachments": [], "todos": [], "prompts": [], "meta": {} }, - "has_more_older": true, "seq": 43 } } -``` - -- baseline reset 恒为 `items: []`(`TRANSCRIPT_RESET_TAIL_TURNS = 0`),历史一律走 REST 分页。 -- 发送时机:首次订阅(history backfill 完成后)、grade 升级、roster 出现新 agent。 - -### 4.3 粒度过滤 - -同一 store 变更,不同 grade 的下发内容: - -| op 类型 | off | turn | block | delta | -|---|---|---|---|---| -| turn.upsert / meta.merge / task / interaction / marker / todo / prompt / attachment / items.remove | — | ✓ | ✓ | ✓ | -| step.upsert / frame.upsert | — | — | ✓(全量帧) | ✓ | -| append | — | — | — | ✓ | -| reset 快照 | — | turn 的 steps 掏空为 `[]` | 完整 | 完整 | - -block 订阅者在流式期间只收 `frame.upsert` 空帧;step 完成时 projector 的 flushOpenFrames 会补发一次全量帧,因此 block 级也能拿到完整文本。 - -### 4.4 legacy 事件抑制 - -连接对某 agent 订阅了 transcript(grade ≠ off)后,该连接 × agent 的 transcript 投影类 legacy session_event(`TRANSCRIPT_PROJECTED_EVENT_TYPES`,49 种)不再下发;journal 仍记录,未订阅连接不受影响。`prompt.queued` 是唯一例外(不在抑制集,双通道都发)。 - -## 五、REST API - -均包 `{ code, msg, data, request_id }` 信封。 - -### 5.1 `GET /sessions/{id}/transcript` - -query:`agent_id`(必填)、`before_turn | after_turn`(互斥)、`page_size`(1-100,默认尾页 20 turn)。 - -```json -{ "agent_id": "main", "items": [ /* Turn | Marker | TaskRef */ ], - "has_more": true, - "tasks": [], "interactions": [], "attachments": [], "todos": [], - "prompts": [], "meta": {}, - "agents": [ /* AgentDescriptor */ ], - "pending_interactions": [], "seq": 43 } -``` - -`seq` 是该 agent 当前 watermark。live 读内存 store,cold 从 wire.jsonl 重建。 - -### 5.2 `GET /sessions/{id}/transcript/ops` - -query:`agent_id`、`since_seq`。 - -```json -{ "agent_id": "main", - "batches": [ { "seq": 43, "ops": [] } ], - "latest_seq": 47, - "complete": true } -``` - -`complete: false` = journal 覆盖不到或 session 冷 → 调用方全量刷新。 - -### 5.3 `GET /sessions/{id}/transcript/user-messages` - -按 agent 返回用户消息列表: - -```json -{ "agents": [ { "agent_id": "main", - "messages": [ { "turn_id": "t1", "ordinal": 1, "state": "completed", - "origin": { "kind": "user" }, "prompt": "...", - "attachment_ids": [], "started_at": "..." } ], - "attachments": [] } ] } -``` - -### 5.4 `GET /sessions/{id}/transcript/plan` - -query:`agent_id`(必填)、`tool_call_id`(可选,窄化到单个调用)。 - -```json -{ "agent_id": "main", - "plans": [ { "tool_call_id": "call_1", "turn_id": "t1", - "source": "interaction", "plan": "...", "path": "...", - "options": [ { "label": "...", "description": "..." } ], - "review": { "state": "approved", "selected_option": "...", "feedback": "..." } } ] } -``` - -## 六、Session 级 work 状态 - -session 粒度的忙闲由 core 的 `ISessionActivityView` 聚合,经 `event.session.work_changed` 下发: - -```json -{ "busy": false, "main_turn_active": false, - "pending_interaction": "none", "last_turn_reason": "completed" } -``` - -`pending_interaction` ∈ `none | approval | question`;`last_turn_reason` ∈ `completed | cancelled | failed`。同一 view 也服务 REST 的 session facts 与 v2 `activity.status`(`approval > question > running > failed > idle`)。 - -## 七、事件来源 - -live projector 消费的 core observable 事件(主要):`turn.started`、`turn.ended`、`turn.step.started/completed/interrupted/retrying`、`assistant.delta`、`thinking.delta`、`tool.call.started/delta`、`tool.result`、`tool.progress`、`task.started/terminated/notified`、`shell.started/output/completed`、`subagent.spawned/started/completed/failed/suspended`、`prompt.accepted/queued/submitted/started/completed/aborted/steered`、`goal.updated`、`agent.status.updated`、`agent.activity.updated`、`interaction.request/resolved`(经 session 交互状态订阅)、`error`、`warning`、`hook.result`、`cron.fired`、`skill.activated`、`plugin_command.activated`、`compaction.*`、`context.spliced/undone`。 - -cold fold 消费的 durable record type:`turn.prompt`、`turn.ended`、`turn.cancel`、`turn.steer`、`turn.step.interrupted`、`context.append_message`、`context.append_loop_event`(内嵌 `step.begin` / `step.end` / `content.part` / `tool.call` / `tool.result`)、`context.undo/clear/apply_compaction`、`interaction.request/resolved`、`task.started/terminated`、`goal.create/update/clear`、`plan_mode.enter/exit/cancel`、`plan.revision`、`swarm_mode.enter/exit`、`tower_mode.enter/exit`、`tools.update_store`。 - -## 八、版本与迁移约定 - -1. **契约载体**:`src/contract/schema.ts` 的 zod schema 是 wire 契约的唯一权威定义;本文档是其可读形式。两者冲突时以 schema 为准并修正本文档。 -2. **wire.jsonl 只增不改**:允许新增 record type、给既有 record 新增 optional 字段;禁止删除/改名/改语义。旧文件必须永远可回放(zod optional 保证 safeParse 通过)。 -3. **transcript 契约变更必须附带 migration 文档**:任何对实体字段、op 类型、帧结构、REST 响应、grade 语义的增删改,都需要在 `docs/migrations/` 下新增 `NNNN-.md`,编号递增。migration 文档必含五节: - - **变更摘要**:一句话说明改了什么、为什么。 - - **old → new 映射**:字段/枚举/op 的对照表(含删除项的去向)。 - - **对消费方的影响**:kimi-code-app / kimi-inspect / klient / 外部客户端各自需要适配什么。 - - **wire 兼容性**:新增记录类型清单;旧 wire.jsonl 的回放行为。 - - **回滚**:如何回退,回退后旧客户端看到什么。 -4. **纯新增(新 op、新 optional 字段、新枚举值且有默认处理)只需 changeset**,不需要 migration 文档;migration 文档针对删除、改名、语义变更。 -5. 规划中的首份 migration:`docs/migrations/0001-state-model-unification.md`(状态模型统一重构,设计稿在工作区,落地时随代码一并进入仓库)。