From 61389c6abdf8db39604a584115c571e994a56f07 Mon Sep 17 00:00:00 2001 From: daofaziran Date: Sun, 2 Aug 2026 11:00:50 +0900 Subject: [PATCH 01/33] perf(agent-core): replace dynamic timestamp with static time-lookup reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ISO timestamp injected into the system prompt changes on every new session, breaking DeepSeek's byte-prefix cache from that point onward — including the ~16.8k-token tools definition that follows it. First-turn input drops from ~19.5k tokens cache-miss to ~88 after the change (measured 99.6% cache hit on the built artifact). The Date and Time section is kept as a static reminder to fetch the real current time from the environment (e.g. via the `date` command) when it matters. --- packages/agent-core-v2/src/app/agentProfileCatalog/system.md | 2 +- packages/agent-core/src/profile/default/system.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md index b8553cad9dc..dc95b310861 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md @@ -82,7 +82,7 @@ The operating environment is not in a sandbox. Any actions you do will immediate ## Date and Time -The current date and time in ISO format is `${now}`. This was captured when the session started and does not update as the session continues, so in a long or resumed session it may be hours or days stale. Treat it only as a rough reference; whenever the real current time matters (web-result freshness, age or expiry checks, anything time-sensitive), get it fresh from the environment — for example by running `date` if you have a shell tool — instead of trusting this value. +The current time is not embedded in this prompt. Whenever the real current time matters — web-result freshness, age or expiry checks, anything time-sensitive — get it fresh from the environment, for example by running `date` if you have a shell tool. ## Working Directory diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index b1b70d33b15..b1b6d804e5f 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -86,7 +86,7 @@ The operating environment is not in a sandbox. Any actions you do will immediate ## Date and Time -The current date and time in ISO format is `{{ KIMI_NOW }}`. This was captured when the session started and does not update as the session continues, so in a long or resumed session it may be hours or days stale. Treat it only as a rough reference; whenever the real current time matters (web-result freshness, age or expiry checks, anything time-sensitive), get it fresh from the environment — for example by running `date` if you have a shell tool — instead of trusting this value. +The current time is not embedded in this prompt. Whenever the real current time matters — web-result freshness, age or expiry checks, anything time-sensitive — get it fresh from the environment, for example by running `date` if you have a shell tool. ## Working Directory From 9218660a0594d7216e9fd099eb57b74f07f460f1 Mon Sep 17 00:00:00 2001 From: daofaziran Date: Sun, 2 Aug 2026 11:00:50 +0900 Subject: [PATCH 02/33] test(agent-core): add regression coverage for timestamp-free system prompt Also add the required changeset (@moonshot-ai/kimi-code patch) for the prompt-cache fix. --- .changeset/perf-system-prompt-cache.md | 5 +++++ .../agentProfileCatalog/profile-shared.test.ts | 15 +++++++++++++++ .../test/profile/default-agent-profiles.test.ts | 8 ++++++++ 3 files changed, 28 insertions(+) create mode 100644 .changeset/perf-system-prompt-cache.md diff --git a/.changeset/perf-system-prompt-cache.md b/.changeset/perf-system-prompt-cache.md new file mode 100644 index 00000000000..b72bc92a0e7 --- /dev/null +++ b/.changeset/perf-system-prompt-cache.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Replace the per-session timestamp in the default system prompt with a static time-lookup reminder. The timestamp changed on every new session, breaking DeepSeek's byte-prefix cache from that point onward — including the ~16.8k-token tools definition — so every new session's first LLM call missed the cache. First-turn cache-miss input drops from ~19.5k tokens to ~88 tokens (99.6% cache hit measured on the built artifact). The static `## Date and Time` section keeps the instruction to fetch the real current time from the environment (e.g. via the `date` command) when it matters, without the cache-breaking value. diff --git a/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts b/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts index beebc039f44..eaa9a772bc6 100644 --- a/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts +++ b/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts @@ -197,6 +197,21 @@ describe('renderSystemPrompt', () => { ); }); + it('keeps a static time reminder without a per-session timestamp', () => { + const nowA = '2026-08-02T10:00:00.000Z'; + const nowB = '2026-08-03T10:00:00.000Z'; + const promptA = renderSystemPrompt('', { now: nowA }, { skillActive: true }); + const promptB = renderSystemPrompt('', { now: nowB }, { skillActive: true }); + + // Byte-identical across sessions — the property the prefix cache depends on. + expect(promptA).toBe(promptB); + // The dynamic value must not leak into the rendered prompt... + expect(promptA).not.toContain(nowA); + // ...but the static time-lookup guidance stays. + expect(promptA).toContain('## Date and Time'); + expect(promptA).toContain('get it fresh from the environment'); + }); + it('renders the builtin template with no leftover placeholders', () => { // Every placeholder in the builtin template must be bound in the variable // table — an unbound one would stay verbatim in the output. diff --git a/packages/agent-core/test/profile/default-agent-profiles.test.ts b/packages/agent-core/test/profile/default-agent-profiles.test.ts index a732f3181a7..0e334d8e82c 100644 --- a/packages/agent-core/test/profile/default-agent-profiles.test.ts +++ b/packages/agent-core/test/profile/default-agent-profiles.test.ts @@ -31,6 +31,14 @@ describe('default agent profiles', () => { expect(prompt).toContain('/workspace'); }); + it('keeps a static time reminder without the dynamic timestamp', () => { + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt(promptContext) ?? ''; + + expect(prompt).not.toContain(promptContext.now); + expect(prompt).toContain('## Date and Time'); + expect(prompt).toContain('get it fresh from the environment'); + }); + it('keeps static instructions before dynamic prompt context', () => { const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt(promptContext) ?? ''; From 01fca143e1d230c9bfcb095742ee94879bd7f752 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 3 Aug 2026 15:31:57 +0800 Subject: [PATCH 03/33] refactor(agent-core-v2): code all domain failure modes as Error2 (#2552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(agent-core-v2): code all domain failure modes as Error2 - wrap bare throws across agent/session/app/workspace/os/wire/kosong/mcpCore domains in coded Error2, keeping messages verbatim and moving structured data into details with the original error as cause - add new wire codes (agent.already_exists/already_running/not_a_subagent/not_owned/type_not_allowed/max_tokens_exceeded, task.limit_exceeded, cron.expression_invalid, web.invalid_url/private_address/fetch_failed, mcp.oauth_failed, skill.parse_failed/nested_too_deep, wire.migration_missing) to the protocol KimiErrorCode union and the kap-server zod schema; register shell.git_bash_not_found and session.plan_mode_invalid - re-base domain error classes onto Error2 (SkillParseError, UnsupportedSkillTypeError, HostFolder*, AgentFileParseError, NestedSkillTooDeepError, AlreadyAuthorizedError, HttpFetchError) keeping class names and instanceof consumers intact - convert caller-bug and unreachable guards outside _base to BugIndicatingError - fix the agent tool's task-limit remap never firing by branching on the task.limit_exceeded code instead of a stale message string * refactor(kosong): make ChatProviderError family born-coded via Error2 - move the provider/context code string constants to kosong/contract/errors.ts and compute each class's wire code at construction (status code / finish reason) - move sanitizeStatusErrorMessage to the contract and fold status details (statusCode / requestId / traceId) into Error2 details at birth - slim translateProviderError down to the abort guard plus the foreign-error fallback; ProtocolErrors keeps registering the domain via re-exported constants - update errors.md conventions and tests for the pass-through behavior * feat(storage): add permission_denied and disk_full error codes - extend StorageErrors with storage.permission_denied / storage.disk_full (both non-retryable, with user-facing actions) - map errno at the backend boundary in toStorageIoError: EACCES/EPERM, ENOSPC, unexpected ENOENT → not_found, everything else io_failed; the message now carries the mapped reason - register the two codes in the KimiErrorCode protocol union and the kap-server zod schema, and document the mapping in errors.md * fix(protocol): mirror all KimiErrorCode values in kimiErrorCodeSchema the zod enum lagged the type union by 35 codes (agent.*, os.fs.*, os.process.*, storage.*, wire.*, skill/task/mcp/cron/web additions), so protocol consumers could type the new codes but rejected them at runtime validation; spotted by Codex review on #2552 --- packages/agent-core-v2/docs/errors.md | 3 +- .../src/agent/contextMemory/contextOps.ts | 37 ++++++- .../conversationUndoParticipants.ts | 3 +- .../agent/llmRequester/llmRequesterService.ts | 7 +- .../src/agent/loop/loopService.ts | 4 +- .../agent-core-v2/src/agent/mcp/tools/mcp.ts | 5 +- .../src/agent/permissionRules/matchesRule.ts | 7 +- .../src/agent/plan/planService.ts | 3 +- .../agent/shellCommand/shellCommandService.ts | 3 +- .../agent-core-v2/src/agent/task/errors.ts | 2 + .../agent-core-v2/src/agent/task/persist.ts | 3 +- .../src/agent/task/taskService.ts | 5 +- .../toolExecutor/beforeToolExecuteEvent.ts | 3 +- .../agent/tools/agent-swarm/agentSwarmTool.ts | 35 ++++-- .../src/agent/tools/agent/agentTool.ts | 43 ++++++-- .../src/agent/tools/skill/skill.ts | 6 +- .../agent/tools/web-search/webSearchTool.ts | 3 +- .../builtinAgentProfileLoaderService.ts | 5 +- .../agent-core-v2/src/app/auth/authService.ts | 5 +- .../providers/moonshot-web-search.ts | 12 ++- .../src/app/config/configService.ts | 3 +- .../agent-core-v2/src/app/cron/cron-expr.ts | 56 +++++++--- packages/agent-core-v2/src/app/cron/errors.ts | 13 +++ .../src/app/flag/flagRegistryService.ts | 3 +- .../src/app/gateway/gatewayService.ts | 13 ++- .../hostFolderBrowser/hostFolderBrowser.ts | 17 +-- .../src/app/kosongConfig/discoveryService.ts | 5 +- .../src/app/kosongConfig/modelsDevUpstream.ts | 15 ++- .../agent-core-v2/src/app/plugin/archive.ts | 28 ++++- .../src/app/plugin/github-resolver.ts | 26 ++++- .../agent-core-v2/src/app/plugin/manager.ts | 42 ++++++-- .../src/app/plugin/pluginService.ts | 5 +- .../agent-core-v2/src/app/plugin/source.ts | 8 +- .../agent-core-v2/src/app/plugin/store.ts | 23 ++-- .../src/app/sessionExport/file-source.ts | 14 ++- .../src/app/skillCatalog/errors.ts | 2 + .../src/app/skillCatalog/parser.ts | 10 +- packages/agent-core-v2/src/app/web/errors.ts | 16 +++ .../src/app/web/providers/local-fetch-url.ts | 46 +++++--- .../app/web/providers/moonshot-fetch-url.ts | 7 +- .../src/app/web/tools/fetch-url-types.ts | 8 +- packages/agent-core-v2/src/errors.ts | 6 ++ packages/agent-core-v2/src/hooks.ts | 5 +- .../src/kosong/contract/errors.ts | 77 ++++++++++--- .../src/kosong/model/inspection.ts | 4 +- .../src/kosong/protocol/errors.ts | 102 +++++------------- .../src/kosong/protocol/protocolBase.ts | 3 +- .../provider/bases/openai/openai-common.ts | 3 +- .../provider/bases/openai/openai-responses.ts | 5 +- .../src/kosong/provider/bases/tool-call-id.ts | 5 +- .../src/kosong/provider/providerDefinition.ts | 3 +- .../provider/providers/kimi/kimi-schema.ts | 23 +++- .../agent-core-v2/src/mcpCore/client-http.ts | 5 +- .../agent-core-v2/src/mcpCore/client-sse.ts | 5 +- .../agent-core-v2/src/mcpCore/client-stdio.ts | 4 +- .../src/mcpCore/connection-manager.ts | 2 +- packages/agent-core-v2/src/mcpCore/errors.ts | 1 + .../src/mcpCore/oauth/provider.ts | 4 +- .../src/mcpCore/oauth/service.ts | 40 +++++-- .../agent-core-v2/src/mcpCore/oauth/store.ts | 4 +- packages/agent-core-v2/src/mcpCore/types.ts | 7 +- .../os/backends/node-local/tools/rgLocator.ts | 53 +++++++-- .../src/os/backends/node-local/tools/runRg.ts | 3 +- .../src/os/interface/hostProcess.ts | 7 ++ .../src/persistence/interface/storage.ts | 55 ++++++++-- .../agentLifecycle/agentLifecycleService.ts | 11 +- .../src/session/agentLifecycle/errors.ts | 6 ++ .../session/cron/sessionCronServiceImpl.ts | 3 +- packages/agent-core-v2/src/session/errors.ts | 1 + .../session/process/processRunnerService.ts | 3 +- .../sessionAgentProfileCatalogService.ts | 3 +- .../src/session/subagent/runAgentTurn.ts | 6 +- .../src/session/subagent/subagentService.ts | 7 +- .../src/session/swarm/agentRunBatch.ts | 7 +- .../src/session/swarm/sessionSwarmService.ts | 31 ++++-- .../workspaceContextService.ts | 5 +- .../agent-core-v2/src/tool/result-builder.ts | 4 +- packages/agent-core-v2/src/wire/errors.ts | 7 ++ .../src/wire/migration/migration.ts | 8 +- .../internal/agentFile.ts | 10 +- .../workspaceFs/internal/rgLocator.ts | 4 +- .../workspaceProcessRunnerService.ts | 3 +- .../test/app/protocol/errors.test.ts | 9 +- .../test/kosong/protocol/errors.test.ts | 4 +- .../kap-server/src/protocol/events-zod.ts | 17 +++ packages/protocol/src/events.ts | 52 +++++++++ 86 files changed, 889 insertions(+), 292 deletions(-) create mode 100644 packages/agent-core-v2/src/app/cron/errors.ts create mode 100644 packages/agent-core-v2/src/app/web/errors.ts diff --git a/packages/agent-core-v2/docs/errors.md b/packages/agent-core-v2/docs/errors.md index e488d07ffe8..c4355465d0c 100644 --- a/packages/agent-core-v2/docs/errors.md +++ b/packages/agent-core-v2/docs/errors.md @@ -22,6 +22,7 @@ unified `ErrorCodes` const. ## 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). @@ -70,7 +71,7 @@ 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`. ENOENT keeps its established absence semantics (`read → undefined`, `list → []`) and is *not* an error; other I/O failures become `storage.io_failed` (`retryable`). 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.) +- **`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`)** — `DuplicateOpError` (`wire.duplicate_op`, a build-time bug), `CycleError` (`wire.cycle`, details carry the drain depth and a capped op-type sample), and `wire.unknown_record`: replay skips records whose Op type is absent from `OP_REGISTRY` (compatibility), reports each skip through `onUnexpectedError`, and returns `{ unknownRecords }` so the caller knows the restore was lossy. ## Serialization & boundary translation diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index f2a1adab15f..1fc5cae6028 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -39,6 +39,7 @@ import { z } from 'zod'; +import { ErrorCodes, Error2 } from '#/errors'; import type { ContentPart } from '#/kosong/contract/message'; import { defineModel, type PartsTransformer } from '#/wire/model'; import type { WireRecord } from '#/wire/record'; @@ -238,7 +239,17 @@ export function readContextCompactedCount(record: ContextCompactionRecord): numb if (typeof compactedCount === 'number') return compactedCount; const legacyCount = fields['count']; if (typeof legacyCount === 'number') return legacyCount; - throw new Error('Invalid context.apply_compaction record: missing compactedCount'); + throw new Error2( + ErrorCodes.STORAGE_DECODE_FAILED, + 'Invalid context.apply_compaction record: missing compactedCount', + { + details: { + recordKeys: Object.keys(record), + compactedCountType: typeof compactedCount, + countType: typeof legacyCount, + }, + }, + ); } export function readContextCompactionSummary(record: ContextCompactionRecord): ContextMessage { @@ -248,7 +259,17 @@ export function readContextCompactionSummary(record: ContextCompactionRecord): C const summary = fields['summary']; if (typeof summary === 'string') return createCompactionSummaryMessage(summary); if (isContextMessage(summary)) return summary; - throw new Error('Invalid context.apply_compaction record: missing summary'); + throw new Error2( + ErrorCodes.STORAGE_DECODE_FAILED, + 'Invalid context.apply_compaction record: missing summary', + { + details: { + recordKeys: Object.keys(record), + summaryType: typeof summary, + contextSummaryType: typeof contextSummary, + }, + }, + ); } function readContextCompactionRawSummary(record: UnknownRecord): string { @@ -259,7 +280,17 @@ function readContextCompactionRawSummary(record: UnknownRecord): string { if (isContextMessage(summary)) { return textOf(summary); } - throw new Error('Invalid context.apply_compaction record: missing summary'); + throw new Error2( + ErrorCodes.STORAGE_DECODE_FAILED, + 'Invalid context.apply_compaction record: missing summary', + { + details: { + recordKeys: Object.keys(record), + summaryType: typeof summary, + contextSummaryType: typeof contextSummary, + }, + }, + ); } function readLegacySummaryMessage(record: UnknownRecord): ContextMessage | undefined { diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts index bf3ef9f3d10..937a5839db6 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts @@ -8,6 +8,7 @@ import { createDecorator } from '#/_base/di/instantiation'; import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { BugIndicatingError } from '#/errors'; export interface AgentConversationUndoParticipant { readonly id: string; @@ -36,7 +37,7 @@ class AgentConversationUndoParticipantRegistry register(participant: AgentConversationUndoParticipant): IDisposable { if (this.participants.has(participant.id)) { - throw new Error( + throw new BugIndicatingError( `Conversation undo participant "${participant.id}" is already registered`, ); } diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index aec58a5d4c4..4b46d1bc5e7 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -96,7 +96,7 @@ import { type LlmRequestToolSchema, } from './llmRequestOps'; import { isAbortError } from '#/_base/utils/abort'; -import { unwrapErrorCause } from '#/errors'; +import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { retryErrorFields } from '#/_base/utils/retry'; const EMPTY_TOOL_PARAMETERS: Record = { @@ -416,7 +416,10 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { } if (message === undefined || finish === undefined) { - throw new Error('LLM request stream ended without a finish event.'); + throw new Error2( + ErrorCodes.PROVIDER_API_ERROR, + 'LLM request stream ended without a finish event.', + ); } this.usage.record(request.modelAlias, usage, request.source); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index df36717a2e0..68d2e9cd844 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -579,7 +579,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { options: LoopErrorHandlerRegistrationOptions = {}, ): IDisposable { if (options.before !== undefined && options.after !== undefined) { - throw new Error('Loop error handler registration cannot specify both before and after'); + throw new BugIndicatingError('Loop error handler registration cannot specify both before and after'); } this.deleteErrorHandler(handler.id); const target = options.before ?? options.after; @@ -588,7 +588,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } else { const targetIndex = this.errorHandlers.findIndex((entry) => entry.id === target); if (targetIndex < 0) { - throw new Error(`Loop error handler target "${target}" is not registered`); + throw new BugIndicatingError(`Loop error handler target "${target}" is not registered`); } const insertAt = options.before !== undefined ? targetIndex : targetIndex + 1; this.errorHandlers.splice(insertAt, 0, handler); diff --git a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts index 9b9f7d648fb..5cda40b2f45 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts @@ -25,7 +25,7 @@ import type { Tool as KosongTool } from '#/kosong/contract/tool'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; -import { toErrorMessage } from '#/errors'; +import { Error2, ErrorCodes, toErrorMessage } from '#/errors'; import { isAbortError } from '#/_base/utils/abort'; import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult } from '#/tool/toolContract'; @@ -118,7 +118,8 @@ async function retryAfterReconnect( if (context.signal.aborted || isAbortError(reconnectError)) { throw reconnectError; } - throw new Error( + throw new Error2( + ErrorCodes.MCP_STARTUP_FAILED, `${toErrorMessage(failure)} (reconnecting the MCP server also failed: ${toErrorMessage(reconnectError)})`, { cause: reconnectError }, ); diff --git a/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts b/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts index f5614cbb9a7..d67ca9d409a 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/matchesRule.ts @@ -1,5 +1,6 @@ import picomatch from 'picomatch'; +import { Error2, ErrorCodes } from '#/errors'; import type { RunnableToolExecution } from '#/tool/toolContract'; import type { PermissionRule } from './permissionRules'; @@ -31,7 +32,7 @@ export interface PermissionRuleMatchInput { export function parsePattern(pattern: string): ParsedPattern { const trimmed = pattern.trim(); if (trimmed.length === 0) { - throw new Error('permission pattern: empty string'); + throw new Error2(ErrorCodes.VALIDATION_FAILED, 'permission pattern: empty string'); } const openIdx = trimmed.indexOf('('); @@ -40,13 +41,13 @@ export function parsePattern(pattern: string): ParsedPattern { } if (!trimmed.endsWith(')')) { - throw new Error(`permission pattern: missing closing paren in "${pattern}"`); + throw new Error2(ErrorCodes.VALIDATION_FAILED, `permission pattern: missing closing paren in "${pattern}"`); } const toolName = trimmed.slice(0, openIdx); const argPattern = trimmed.slice(openIdx + 1, -1); if (toolName.length === 0) { - throw new Error(`permission pattern: empty tool name in "${pattern}"`); + throw new Error2(ErrorCodes.VALIDATION_FAILED, `permission pattern: empty tool name in "${pattern}"`); } if (argPattern.length === 0) { return { toolName }; diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index 255a0c50584..5ca8ddb29d7 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -27,6 +27,7 @@ import { dirname, join } from 'pathe'; import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; +import { Error2, ErrorCodes } from '#/errors'; import { generateHeroSlug } from '#/_base/utils/hero-slug'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; @@ -181,7 +182,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { async enter(id = this.createPlanId(), createFile = false): Promise { if (this.isActive) { - throw new Error('Already in plan mode'); + throw new Error2(ErrorCodes.SESSION_PLAN_MODE_INVALID, 'Already in plan mode'); } const planFilePath = this.planFilePathFor(id); diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts index 1c8afce678d..3512eb8b3d0 100644 --- a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts @@ -31,6 +31,7 @@ import { IAgentStateService } from '#/agent/state/agentState'; import type { ToolUpdate } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IEventBus } from '#/app/event/eventBus'; +import { Error2, ErrorCodes } from '#/errors'; import { IAgentShellCommandService, @@ -200,7 +201,7 @@ export class AgentShellCommandService implements IAgentShellCommandService { private ensureBashTool() { const bash = this.toolRegistry.resolve('Bash'); if (bash === undefined) { - throw new Error('Bash tool is not registered.'); + throw new Error2(ErrorCodes.INTERNAL, 'Bash tool is not registered.'); } return bash; } diff --git a/packages/agent-core-v2/src/agent/task/errors.ts b/packages/agent-core-v2/src/agent/task/errors.ts index 7be6f5b054a..f2e43ace268 100644 --- a/packages/agent-core-v2/src/agent/task/errors.ts +++ b/packages/agent-core-v2/src/agent/task/errors.ts @@ -7,7 +7,9 @@ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; export const TaskErrors = { codes: { TASK_ID_EMPTY: 'task.task_id_empty', + TASK_LIMIT_EXCEEDED: 'task.limit_exceeded', }, + retryable: ['task.limit_exceeded'], } as const satisfies ErrorDomain; registerErrorDomain(TaskErrors); diff --git a/packages/agent-core-v2/src/agent/task/persist.ts b/packages/agent-core-v2/src/agent/task/persist.ts index 6f57d700cdc..9a58e131e7e 100644 --- a/packages/agent-core-v2/src/agent/task/persist.ts +++ b/packages/agent-core-v2/src/agent/task/persist.ts @@ -19,6 +19,7 @@ import { join } from 'pathe'; +import { BugIndicatingError } from '#/errors'; import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import type { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -62,7 +63,7 @@ interface TaskOutputData { function validateTaskId(taskId: string): void { if (!VALID_TASK_ID.test(taskId)) { - throw new Error(`Invalid task id: "${taskId}"`); + throw new BugIndicatingError(`Invalid task id: "${taskId}"`); } } diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 04a9920d4e3..d5078a767ac 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -54,6 +54,7 @@ import { } from '#/_base/utils/abort'; import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IEventBus } from '#/app/event/eventBus'; +import { Error2, ErrorCodes } from '#/errors'; import { defineCheckpointedModel } from '#/agent/contextMemory/conversationTime'; import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types'; @@ -918,7 +919,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { if (maxRunningTasks === undefined) return; if (!detached) return; if (this.activeTaskCount() < maxRunningTasks) return; - throw new Error('Too many background tasks are already running.'); + throw new Error2(ErrorCodes.TASK_LIMIT_EXCEEDED, 'Too many background tasks are already running.', { + details: { running: this.activeTaskCount(), max: maxRunningTasks }, + }); } private activeTaskCount(): number { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts index 099e2ef62e0..7f2f2780712 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts @@ -23,6 +23,7 @@ */ import { Emitter } from '#/_base/event'; +import { BugIndicatingError } from '#/errors'; import type { ToolCall } from '#/kosong/contract/message'; import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import type { @@ -112,7 +113,7 @@ export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent { private assertOpen(statement: string): void { if (!this._open) { - throw new Error(`${statement} can NOT be called asynchronously`); + throw new BugIndicatingError(`${statement} can NOT be called asynchronously`); } } } diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index ddae2a75e0b..87e48f573ac 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -26,6 +26,7 @@ import { type ExecutableToolResult, type ToolExecution, } from '#/tool/toolContract'; +import { Error2, ErrorCodes } from '#/errors'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { IConfigService } from '#/app/config/config'; @@ -171,11 +172,17 @@ export class AgentSwarmTool implements IAgentSwarmTool { const own = this.profile.data(); const allowlist = subagentAllowlistFor(this.catalog, own); if (allowlist !== undefined && !allowlist.includes(profileName)) { - throw new Error(subagentTypeNotAllowedMessage(profileName, allowlist)); + throw new Error2( + ErrorCodes.AGENT_TYPE_NOT_ALLOWED, + subagentTypeNotAllowedMessage(profileName, allowlist), + { details: { profileName, allowlist } }, + ); } const targetProfile = this.catalog.get(profileName); if (targetProfile === undefined) { - throw new Error(`Unknown agent type: "${profileName}"`); + throw new Error2(ErrorCodes.PROFILE_UNKNOWN, `Unknown agent type: "${profileName}"`, { + details: { profileName }, + }); } if (own.modelAlias !== undefined) { binding = resolveSubagentBinding( @@ -242,18 +249,30 @@ async function createAgentSwarmSpecs( const resumeCount = resumeEntries.length; const totalCount = resumeCount + itemCount; if (!hasMinimumAgentSwarmInputs(itemCount, resumeCount)) { - throw new Error('AgentSwarm requires at least 2 items unless resume_agent_ids is provided.'); + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + 'AgentSwarm requires at least 2 items unless resume_agent_ids is provided.', + ); } if (totalCount > MAX_AGENT_SWARM_SUBAGENTS) { - throw new Error(`AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`); + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`, + { details: { total: totalCount, max: MAX_AGENT_SWARM_SUBAGENTS } }, + ); } const promptTemplate = normalizeOptionalString(args.prompt_template); if (items.length > 0 && promptTemplate === undefined) { - throw new Error('prompt_template is required when items are provided.'); + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + 'prompt_template is required when items are provided.', + ); } if (promptTemplate !== undefined && !promptTemplate.includes(PROMPT_TEMPLATE_PLACEHOLDER)) { - throw new Error( + throw new Error2( + ErrorCodes.VALIDATION_FAILED, `prompt_template must include the ${PROMPT_TEMPLATE_PLACEHOLDER} placeholder.`, + { details: { placeholder: PROMPT_TEMPLATE_PLACEHOLDER } }, ); } @@ -274,8 +293,10 @@ async function createAgentSwarmSpecs( const prompt = itemPromptTemplate.split(PROMPT_TEMPLATE_PLACEHOLDER).join(item); const previousIndex = seenPrompts.get(prompt); if (previousIndex !== undefined) { - throw new Error( + throw new Error2( + ErrorCodes.VALIDATION_FAILED, `Duplicate subagent prompts from items ${String(previousIndex)} and ${String(index + 1)}. AgentSwarm requires distinct subagents.`, + { details: { previousIndex, index: index + 1 } }, ); } seenPrompts.set(prompt, index + 1); 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 1795eeda2fe..64d8aa9dde1 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -32,6 +32,7 @@ import { isUserCancellation, userCancellationReason, } from '#/_base/utils/abort'; +import { Error2, ErrorCodes, isError2 } from '#/errors'; import { toInputJsonSchema } from '#/tool/input-schema'; import { matchesGlobRuleSubject } from '#/tool/rule-match'; import { @@ -240,7 +241,11 @@ export class SubagentTool implements ISubagentTool { ): Promise { const requester = this.lifecycle.get(this.callerAgentId); if (requester === undefined) { - throw new Error(`Caller agent "${this.callerAgentId}" does not exist`); + throw new Error2( + ErrorCodes.AGENT_NOT_FOUND, + `Caller agent "${this.callerAgentId}" does not exist`, + { details: { agentId: this.callerAgentId } }, + ); } const resumeAgentId = args.resume?.trim(); @@ -252,7 +257,9 @@ export class SubagentTool implements ISubagentTool { if (isResume) { const target = this.lifecycle.get(resumeAgentId); if (target === undefined) { - throw new Error(`Agent instance "${resumeAgentId}" does not exist`); + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${resumeAgentId}" does not exist`, { + details: { agentId: resumeAgentId }, + }); } await this.ensureOwnedIdleSubagent(resumeAgentId, target); agentId = target.id; @@ -266,14 +273,22 @@ export class SubagentTool implements ISubagentTool { const own = this.profile.data(); const allowlist = subagentAllowlistFor(this.catalog, own); if (allowlist !== undefined && !allowlist.includes(requestedProfileName)) { - throw new Error(subagentTypeNotAllowedMessage(requestedProfileName, allowlist)); + throw new Error2( + ErrorCodes.AGENT_TYPE_NOT_ALLOWED, + subagentTypeNotAllowedMessage(requestedProfileName, allowlist), + { details: { profileName: requestedProfileName, allowlist } }, + ); } const profile = this.catalog.get(requestedProfileName); if (profile === undefined) { - throw new Error(`Unknown agent type: "${requestedProfileName}"`); + throw new Error2(ErrorCodes.PROFILE_UNKNOWN, `Unknown agent type: "${requestedProfileName}"`, { + details: { profileName: requestedProfileName }, + }); } if (own.modelAlias === undefined) { - throw new Error('Caller agent has no model bound'); + throw new Error2(ErrorCodes.MODEL_NOT_CONFIGURED, 'Caller agent has no model bound', { + details: { agentId: this.callerAgentId }, + }); } const binding = resolveSubagentBinding( this.config, @@ -342,13 +357,23 @@ export class SubagentTool implements ISubagentTool { ): Promise { const meta = (await this.sessionMetadata.read()).agents?.[agentId]; if (!isSubagentMeta(meta)) { - throw new Error(`Agent instance "${agentId}" is not a subagent`); + throw new Error2(ErrorCodes.AGENT_NOT_A_SUBAGENT, `Agent instance "${agentId}" is not a subagent`, { + details: { agentId }, + }); } if (subagentParentAgentId(meta) !== this.callerAgentId) { - throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`); + throw new Error2( + ErrorCodes.AGENT_NOT_OWNED, + `Agent instance "${agentId}" does not belong to this parent agent`, + { details: { agentId, callerAgentId: this.callerAgentId } }, + ); } if (target.accessor.get(IAgentLoopService).status().state === 'running') { - throw new Error(`Agent instance "${agentId}" is already running and cannot run concurrently`); + throw new Error2( + ErrorCodes.AGENT_ALREADY_RUNNING, + `Agent instance "${agentId}" is already running and cannot run concurrently`, + { details: { agentId } }, + ); } } @@ -422,7 +447,7 @@ export class SubagentTool implements ISubagentTool { const message = error instanceof Error ? error.message : String(error); return { output: - message === 'Too many detached tasks are already running.' + isError2(error) && error.code === ErrorCodes.TASK_LIMIT_EXCEEDED ? 'Too many background tasks are already running.' : message, isError: true, diff --git a/packages/agent-core-v2/src/agent/tools/skill/skill.ts b/packages/agent-core-v2/src/agent/tools/skill/skill.ts index eca7445a129..6dfaa2bd68a 100644 --- a/packages/agent-core-v2/src/agent/tools/skill/skill.ts +++ b/packages/agent-core-v2/src/agent/tools/skill/skill.ts @@ -13,20 +13,22 @@ import { z } from 'zod'; import { createDecorator } from '#/_base/di/instantiation'; +import { Error2, ErrorCodes } from '#/errors'; import { type AgentTool } from '#/tool/toolContract'; export const MAX_SKILL_QUERY_DEPTH = 3; -export class NestedSkillTooDeepError extends Error { +export class NestedSkillTooDeepError extends Error2 { readonly skillName?: string; readonly depth: number; constructor(depth: number, skillName?: string) { const label = skillName !== undefined ? ` "${skillName}"` : ''; super( + ErrorCodes.SKILL_NESTED_TOO_DEEP, `Nested skill invocation${label} exceeded the maximum depth of ${String(depth)} — refusing to recurse further.`, + { name: 'NestedSkillTooDeepError', details: { depth, skillName } }, ); - this.name = 'NestedSkillTooDeepError'; this.depth = depth; if (skillName !== undefined) this.skillName = skillName; } diff --git a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts index 38e86ca8209..c0e9e0d9c26 100644 --- a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts +++ b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts @@ -23,6 +23,7 @@ import { import { ToolResultBuilder } from '#/tool/result-builder'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { IWebSearchProviderService } from '#/app/auth/webSearch/webSearch'; +import { Error2, ErrorCodes } from '#/errors'; import { IWebSearchTool, @@ -46,7 +47,7 @@ export class WebSearchTool implements IWebSearchTool { ) { const provider = providerService.getWebSearchProvider(); if (provider === undefined) { - throw new Error('WebSearchProviderService returned no provider during tool activation.'); + throw new Error2(ErrorCodes.INTERNAL, 'WebSearchProviderService returned no provider during tool activation.'); } this.provider = provider; } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts index 042a4bf780a..06ad5bd894c 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/builtinAgentProfileLoaderService.ts @@ -6,13 +6,14 @@ * `IAgentProfileRegistry`. Register-after-construction is not supported: like * `IAgentToolRegistryService`, contributions are expected to accumulate at * import time before the container resolves the service. `getDefault()` - * throws a plain `Error` when the builtin default profile is missing — a + * throws a `BugIndicatingError` when the builtin default profile is missing — a * programming-time invariant violation, not a request failure. Bound at App * scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { BugIndicatingError } from '#/errors'; import type { AgentProfile } from './agentProfileCatalog'; import { DEFAULT_AGENT_PROFILE_NAME } from './agentProfileCatalog'; @@ -54,7 +55,7 @@ export class BuiltinAgentProfileLoaderService getDefault(): AgentProfile { const profile = this.byName.get(DEFAULT_AGENT_PROFILE_NAME); if (profile === undefined) { - throw new Error( + throw new BugIndicatingError( `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, ); } diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index a147eff8018..9b581c45345 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -45,6 +45,7 @@ import type { import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2, ErrorCodes } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; @@ -317,7 +318,9 @@ export class OAuthService extends Disposable implements IOAuthService { }); const tokenProvider = this.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); if (tokenProvider === undefined) { - throw new Error('OAuth token provider is not configured.'); + throw new Error2(ErrorCodes.AUTH_TOKEN_MISSING, 'OAuth token provider is not configured.', { + details: { provider_id: KIMI_CODE_PROVIDER_NAME }, + }); } const token = await tokenProvider.getAccessToken(); const models = await fetchManagedKimiCodeModels({ diff --git a/packages/agent-core-v2/src/app/auth/webSearch/providers/moonshot-web-search.ts b/packages/agent-core-v2/src/app/auth/webSearch/providers/moonshot-web-search.ts index de85211e966..d75812f9363 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/providers/moonshot-web-search.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/providers/moonshot-web-search.ts @@ -1,4 +1,5 @@ import type { WebSearchProvider, WebSearchResult } from '#/agent/tools/web-search/web-search'; +import { Error2, ErrorCodes } from '#/errors'; export interface BearerTokenProvider { getAccessToken(options?: { readonly force?: boolean | undefined }): Promise; @@ -60,15 +61,19 @@ export class MoonshotWebSearchProvider implements WebSearchProvider { if (response.status === 401) { const detail = await safeReadText(response); - throw new Error( + throw new Error2( + ErrorCodes.WEB_FETCH_FAILED, `Moonshot search request failed: HTTP 401 (auth/unauthorized). ${detail}`.trim(), + { details: { status: response.status } }, ); } if (response.status !== 200) { const detail = await safeReadText(response); - throw new Error( + throw new Error2( + ErrorCodes.WEB_FETCH_FAILED, `Moonshot search request failed: HTTP ${String(response.status)}. ${detail}`.trim(), + { details: { status: response.status } }, ); } @@ -119,7 +124,8 @@ export class MoonshotWebSearchProvider implements WebSearchProvider { } } if (this.apiKey !== undefined && this.apiKey.length > 0) return this.apiKey; - throw new Error( + throw new Error2( + ErrorCodes.AUTH_TOKEN_MISSING, 'Moonshot search service is not configured: missing API key or token provider.', ); } diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 05ba4b0c0ee..8546c80c9bd 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -25,6 +25,7 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; +import { BugIndicatingError } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ILogService } from '#/_base/log/log'; import { @@ -170,7 +171,7 @@ export class ConfigRegistry implements IConfigRegistry { ) { return; } - throw new Error(`ConfigRegistry: section '${domain}' is already registered`); + throw new BugIndicatingError(`ConfigRegistry: section '${domain}' is already registered`); } this.sections.set(domain, { domain, diff --git a/packages/agent-core-v2/src/app/cron/cron-expr.ts b/packages/agent-core-v2/src/app/cron/cron-expr.ts index ea5548f6a14..bf50e875423 100644 --- a/packages/agent-core-v2/src/app/cron/cron-expr.ts +++ b/packages/agent-core-v2/src/app/cron/cron-expr.ts @@ -17,6 +17,8 @@ * that. */ +import { Error2, ErrorCodes } from '#/errors'; + /** A parsed cron expression. Opaque to callers — pass it back into {@link computeNextCronRun}. */ export interface ParsedCronExpression { readonly raw: string; @@ -39,16 +41,20 @@ const MS_PER_MINUTE = 60_000; export function parseCronExpression(expr: string): ParsedCronExpression { if (typeof expr !== 'string') { - throw new TypeError('cron expression must be a string'); + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, 'cron expression must be a string', { + details: { received: typeof expr }, + }); } const trimmed = expr.trim(); if (trimmed === '') { - throw new Error('cron expression is empty'); + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, 'cron expression is empty'); } const fields = trimmed.split(/\s+/); if (fields.length !== 5) { - throw new Error( + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, `cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week); got ${fields.length}`, + { details: { fieldCount: fields.length } }, ); } const [minField, hourField, domField, monthField, dowField] = fields as [ @@ -85,18 +91,26 @@ function isWildcard(field: string): boolean { function parseField(field: string, min: number, max: number, name: string): Set { if (field === '') { - throw new Error(`cron ${name} field is empty`); + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, `cron ${name} field is empty`, { + details: { field: name }, + }); } const out = new Set(); const terms = field.split(','); for (const term of terms) { if (term === '') { - throw new Error(`cron ${name} field has empty term in list`); + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} field has empty term in list`, + { details: { field: name } }, + ); } addTerm(out, term, min, max, name); } if (out.size === 0) { - throw new Error(`cron ${name} field matches no values`); + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, `cron ${name} field matches no values`, { + details: { field: name }, + }); } return out; } @@ -105,8 +119,10 @@ const DIGIT_ONLY = /^\d+$/; function parseCronInt(raw: string, name: string, role: string): number { if (!DIGIT_ONLY.test(raw)) { - throw new Error( + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, `cron ${name} ${role} must be a non-negative integer with digits only (got ${JSON.stringify(raw)})`, + { details: { field: name, role, value: raw } }, ); } return Number.parseInt(raw, 10); @@ -120,15 +136,25 @@ function addTerm(out: Set, term: string, min: number, max: number, name: rangePart = term.slice(0, slash); const stepStr = term.slice(slash + 1); if (stepStr === '') { - throw new Error(`cron ${name} step is empty in "${term}"`); + throw new Error2(ErrorCodes.CRON_EXPRESSION_INVALID, `cron ${name} step is empty in "${term}"`, { + details: { field: name, term }, + }); } const parsedStep = parseCronInt(stepStr, name, 'step'); if (parsedStep <= 0) { - throw new Error(`cron ${name} step must be a positive integer (got "${stepStr}")`); + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} step must be a positive integer (got "${stepStr}")`, + { details: { field: name, term, step: stepStr } }, + ); } step = parsedStep; if (rangePart === '') { - throw new Error(`cron ${name} step needs a range or "*" before "/" in "${term}"`); + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} step needs a range or "*" before "/" in "${term}"`, + { details: { field: name, term } }, + ); } } @@ -142,7 +168,11 @@ function addTerm(out: Set, term: string, min: number, max: number, name: if (dash === -1) { const single = parseCronInt(rangePart, name, 'value'); if (single < min || single > max) { - throw new Error(`cron ${name} value ${single} out of range ${min}..${max}`); + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, + `cron ${name} value ${single} out of range ${min}..${max}`, + { details: { field: name, value: single, min, max } }, + ); } if (slash !== -1) { lo = single; @@ -157,8 +187,10 @@ function addTerm(out: Set, term: string, min: number, max: number, name: lo = parseCronInt(loStr, name, 'range lower bound'); hi = parseCronInt(hiStr, name, 'range upper bound'); if (lo < min || hi > max || lo > hi) { - throw new Error( + throw new Error2( + ErrorCodes.CRON_EXPRESSION_INVALID, `cron ${name} range ${lo}-${hi} out of bounds (must be ${min}..${max}, ascending)`, + { details: { field: name, lo, hi, min, max } }, ); } } diff --git a/packages/agent-core-v2/src/app/cron/errors.ts b/packages/agent-core-v2/src/app/cron/errors.ts new file mode 100644 index 00000000000..02730b44601 --- /dev/null +++ b/packages/agent-core-v2/src/app/cron/errors.ts @@ -0,0 +1,13 @@ +/** + * `cron` domain error codes. + */ + +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const CronErrors = { + codes: { + CRON_EXPRESSION_INVALID: 'cron.expression_invalid', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(CronErrors); diff --git a/packages/agent-core-v2/src/app/flag/flagRegistryService.ts b/packages/agent-core-v2/src/app/flag/flagRegistryService.ts index 610580f07dd..ae1dfdd3e90 100644 --- a/packages/agent-core-v2/src/app/flag/flagRegistryService.ts +++ b/packages/agent-core-v2/src/app/flag/flagRegistryService.ts @@ -8,6 +8,7 @@ import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { BugIndicatingError } from '#/errors'; import { type FlagDefinitionInput, @@ -46,7 +47,7 @@ export class FlagRegistryService extends Disposable implements IFlagRegistry { private add(definition: FlagDefinitionInput): void { if (this.byId.has(definition.id)) { - throw new Error(`Flag '${definition.id}' is already registered`); + throw new BugIndicatingError(`Flag '${definition.id}' is already registered`); } this.byId.set(definition.id, definition); } diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index 8dc8f2b1ae0..dba32d345ed 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -16,6 +16,7 @@ import { registerScopedService, } from '#/_base/di/scope'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { Error2, ErrorCodes } from '#/errors'; import { ILogService } from '#/_base/log/log'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; @@ -34,10 +35,18 @@ export class RestGateway implements IRestGateway { private agent(sessionId: string, agentId: string): IAgentScopeHandle { const session = this.liveSession(sessionId); - if (session === undefined) throw new Error(`unknown session '${sessionId}'`); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `unknown session '${sessionId}'`, { + details: { sessionId }, + }); + } const agents = session.accessor.get(IAgentLifecycleService); const agent = agents.get(agentId); - if (agent === undefined) throw new Error(`unknown agent '${agentId}'`); + if (agent === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `unknown agent '${agentId}'`, { + details: { agentId, sessionId }, + }); + } return agent; } diff --git a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts index dcb715227a7..6db7396fda6 100644 --- a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts +++ b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowser.ts @@ -13,6 +13,9 @@ import { z } from 'zod'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { CoreErrors } from '#/_base/errors/codes'; +import { Error2 } from '#/_base/errors/errors'; +import { FsErrors } from '#/workspace/workspaceFs/internal/errors'; export const fsBrowseQuerySchema = z.object({ path: z.string().min(1).optional(), @@ -39,28 +42,30 @@ export const fsHomeResponseSchema = z.object({ }); export type FsHomeResponse = z.infer; -export class HostFolderNotAbsoluteError extends Error { +export class HostFolderNotAbsoluteError extends Error2 { readonly path: string; constructor(path: string) { - super(`path must be absolute: ${path}`); + super(CoreErrors.codes.VALIDATION_FAILED, `path must be absolute: ${path}`, { + details: { path }, + }); this.name = 'HostFolderNotAbsoluteError'; this.path = path; } } -export class HostFolderNotFoundError extends Error { +export class HostFolderNotFoundError extends Error2 { readonly path: string; constructor(path: string) { - super(`path not found: ${path}`); + super(FsErrors.codes.FS_PATH_NOT_FOUND, `path not found: ${path}`, { details: { path } }); this.name = 'HostFolderNotFoundError'; this.path = path; } } -export class HostFolderPermissionError extends Error { +export class HostFolderPermissionError extends Error2 { readonly path: string; constructor(path: string) { - super(`permission denied: ${path}`); + super(FsErrors.codes.FS_PERMISSION_DENIED, `permission denied: ${path}`, { details: { path } }); this.name = 'HostFolderPermissionError'; this.path = path; } diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index 6b0110d93f1..fc7a77a08f3 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -46,6 +46,7 @@ import { import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import { IOAuthService } from '#/app/auth/auth'; +import { AuthErrors } from '#/app/auth/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; @@ -282,7 +283,9 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { oauthRef as unknown as OAuthRef | undefined, ); if (tokenProvider === undefined) { - throw new Error('OAuth token provider is not configured.'); + throw new Error2(AuthErrors.codes.AUTH_TOKEN_MISSING, 'OAuth token provider is not configured.', { + details: { provider_id: providerName }, + }); } return tokenProvider.getAccessToken(); } diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts index 4a112b62c4b..9ec40db4c36 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts @@ -4,7 +4,8 @@ * item mapping behind the import service's browse methods. */ -import { Error2 } from '#/_base/errors/errors'; +import { CoreErrors } from '#/_base/errors/codes'; +import { BugIndicatingError, Error2 } from '#/_base/errors/errors'; import type { ModelCapability } from '#/kosong/contract/capability'; import type { ModelRecord } from '#/kosong/model/model'; @@ -79,10 +80,14 @@ async function fetchAndCache(): Promise { headers: { Accept: 'application/json', 'User-Agent': 'kimi-code-kap-server' }, signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS), }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + throw new Error2(CoreErrors.codes.INTERNAL, `HTTP ${res.status}`, { + details: { status: res.status }, + }); + } const payload: unknown = await res.json(); if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { - throw new Error('unexpected catalog payload shape'); + throw new Error2(CoreErrors.codes.INTERNAL, 'unexpected catalog payload shape'); } cache = { catalog: payload as ModelsDevCatalog, fetchedAt: now }; return cache.catalog; @@ -176,7 +181,9 @@ export function toModelsDevProviderItem( reject_reason: resolution.reason, }; } - throw new Error(`unhandled models.dev import resolution: ${JSON.stringify(resolution)}`); + throw new BugIndicatingError( + `unhandled models.dev import resolution: ${JSON.stringify(resolution)}`, + ); } diff --git a/packages/agent-core-v2/src/app/plugin/archive.ts b/packages/agent-core-v2/src/app/plugin/archive.ts index eae97f1a502..781bdc6ea3f 100644 --- a/packages/agent-core-v2/src/app/plugin/archive.ts +++ b/packages/agent-core-v2/src/app/plugin/archive.ts @@ -5,6 +5,8 @@ import { pipeline } from 'node:stream/promises'; import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; +import { Error2, ErrorCodes } from '#/errors'; + export async function downloadZip(url: string, signal?: AbortSignal): Promise { const controller = new AbortController(); const timeoutHandle = setTimeout(() => { @@ -13,7 +15,11 @@ export async function downloadZip(url: string, signal?: AbortSignal): Promise((resolve, reject) => { yauzlFromBuffer(buffer, { lazyEntries: true }, (openErr, zipfile) => { if (openErr !== null || zipfile === undefined) { - reject(new Error(`Failed to open zip: ${openErr?.message ?? 'unknown error'}`)); + reject( + new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to open zip: ${openErr?.message ?? 'unknown error'}`, + { cause: openErr ?? undefined }, + ), + ); return; } @@ -40,7 +52,13 @@ export async function extractZip(buffer: Buffer, destDir: string): Promise\` to bypass release lookup.`, + { details: { owner, repo, status: resp.status, url } }, ); } diff --git a/packages/agent-core-v2/src/app/plugin/manager.ts b/packages/agent-core-v2/src/app/plugin/manager.ts index f0e13c3473c..2b6fae8549f 100644 --- a/packages/agent-core-v2/src/app/plugin/manager.ts +++ b/packages/agent-core-v2/src/app/plugin/manager.ts @@ -9,7 +9,7 @@ import { cp, mkdir, mkdtemp, realpath, rename, rm, stat } from 'node:fs/promises import { tmpdir } from 'node:os'; import path from 'node:path'; -import { Error2, PluginErrors } from '#/errors'; +import { BugIndicatingError, Error2, ErrorCodes, PluginErrors } from '#/errors'; import type { HookDef } from '#/agent/externalHooks/types'; import type { McpServerConfig } from '#/mcpCore/config-schema'; import type { PluginAgentRoot } from './types'; @@ -125,10 +125,12 @@ export class PluginManager { const parsed = await parseManifest(sourceRoot); if (parsed.manifest === undefined) { const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest'; - throw new Error( + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, sourceType === 'local-path' ? `Cannot install plugin at ${sourceRoot}: ${msg}` : `Cannot install plugin from ${originalSource}: ${msg}`, + { details: { sourceType } }, ); } @@ -165,10 +167,16 @@ export class PluginManager { try { await rollbackManagedPluginCopy(managedCopy); } catch (rollbackError) { - throw new AggregateError( - [error, rollbackError], + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, 'Plugin installation failed and the previous managed copy could not be restored', - { cause: error }, + { + cause: new AggregateError( + [error, rollbackError], + 'Plugin installation failed and the previous managed copy could not be restored', + { cause: error }, + ), + }, ); } } @@ -196,7 +204,11 @@ export class PluginManager { const current = this.records.get(key); if (current === undefined) throw pluginNotFound(id); if (current.manifest?.mcpServers?.[server] === undefined) { - throw new Error(`Plugin "${id}" does not declare MCP server "${server}"`); + throw new Error2( + ErrorCodes.MCP_SERVER_NOT_FOUND, + `Plugin "${id}" does not declare MCP server "${server}"`, + { details: { id, server } }, + ); } const currentMcpServers = current.capabilities?.mcpServers ?? {}; const nextCapabilities: PluginCapabilityState = { @@ -416,7 +428,8 @@ async function installedGithubSha( async function checkGithubUpdate(record: PluginRecord): Promise { const github = record.github; - if (github === undefined) throw new Error(`Plugin "${record.id}" has no GitHub metadata`); + if (github === undefined) + throw new BugIndicatingError(`Plugin "${record.id}" has no GitHub metadata`); const current = github.ref; const pinned = explicitGithubRef(record); @@ -487,16 +500,25 @@ function pluginNotFound(id: string): Error2 { async function normalizeInstallRoot(rootPath: string): Promise { const trimmed = rootPath.trim(); if (!path.isAbsolute(trimmed)) { - throw new Error(`Plugin root must be an absolute path (got "${rootPath}")`); + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `Plugin root must be an absolute path (got "${rootPath}")`, + { details: { path: rootPath } }, + ); } let resolved: string; try { resolved = await realpath(trimmed); } catch (error) { - throw new Error(`Plugin root does not exist: ${trimmed}`, { cause: error }); + throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `Plugin root does not exist: ${trimmed}`, { + cause: error, + details: { path: trimmed }, + }); } if (!(await stat(resolved)).isDirectory()) { - throw new Error(`Plugin root is not a directory: ${trimmed}`); + throw new Error2(ErrorCodes.VALIDATION_FAILED, `Plugin root is not a directory: ${trimmed}`, { + details: { path: trimmed }, + }); } return resolved; } diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index a215a5b0ae9..84226957bb2 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -15,7 +15,7 @@ import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { Error2, PluginErrors } from '#/errors'; +import { BugIndicatingError, Error2, PluginErrors } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IProviderService } from '#/kosong/provider/provider'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; @@ -86,7 +86,8 @@ export class PluginService extends Disposable implements IPluginService { return this.runSerializedOperation(async () => { const record = await this.manager.install(input.source); const info = this.manager.info(record.id); - if (info === undefined) throw new Error(`Plugin "${record.id}" missing right after install`); + if (info === undefined) + throw new BugIndicatingError(`Plugin "${record.id}" missing right after install`); return info; }); } diff --git a/packages/agent-core-v2/src/app/plugin/source.ts b/packages/agent-core-v2/src/app/plugin/source.ts index a2092c087e8..7909e81a98c 100644 --- a/packages/agent-core-v2/src/app/plugin/source.ts +++ b/packages/agent-core-v2/src/app/plugin/source.ts @@ -1,5 +1,7 @@ import path from 'node:path'; +import { Error2, ErrorCodes } from '#/errors'; + export interface GithubRef { readonly kind: 'branch' | 'tag' | 'sha'; readonly value: string; @@ -24,7 +26,11 @@ export function resolveInstallSource(source: string): ResolvedSource { return { kind: 'zip-url', path: trimmed }; } if (!path.isAbsolute(trimmed)) { - throw new Error(`Plugin root must be an absolute path (got "${source}")`); + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `Plugin root must be an absolute path (got "${source}")`, + { details: { source } }, + ); } return { kind: 'local-path', path: trimmed }; } diff --git a/packages/agent-core-v2/src/app/plugin/store.ts b/packages/agent-core-v2/src/app/plugin/store.ts index 6439ae235cb..e367c7cdee4 100644 --- a/packages/agent-core-v2/src/app/plugin/store.ts +++ b/packages/agent-core-v2/src/app/plugin/store.ts @@ -1,6 +1,8 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { Error2, ErrorCodes } from '#/errors'; + import type { PluginCapabilityState, PluginGithubMetadata, PluginSource } from './types'; const INSTALLED_REL = path.join('plugins', 'installed.json'); @@ -33,15 +35,24 @@ export async function readInstalled(kimiHomeDir: string): Promise if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY; throw error; } + let parsed: InstalledFile; try { - const parsed = JSON.parse(text) as InstalledFile; - if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) { - throw new Error('installed.json is not a valid InstalledFile object'); - } - return parsed; + parsed = JSON.parse(text) as InstalledFile; } catch (error) { - throw new Error(`Failed to parse ${filePath}: ${(error as Error).message}`, { cause: error }); + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to parse ${filePath}: ${(error as Error).message}`, + { cause: error, details: { path: filePath } }, + ); + } + if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) { + throw new Error2( + ErrorCodes.PLUGIN_LOAD_FAILED, + `Failed to parse ${filePath}: installed.json is not a valid InstalledFile object`, + { details: { path: filePath } }, + ); } + return parsed; } export async function writeInstalled(kimiHomeDir: string, data: InstalledFile): Promise { diff --git a/packages/agent-core-v2/src/app/sessionExport/file-source.ts b/packages/agent-core-v2/src/app/sessionExport/file-source.ts index a2b4fc9fc31..49494d46072 100644 --- a/packages/agent-core-v2/src/app/sessionExport/file-source.ts +++ b/packages/agent-core-v2/src/app/sessionExport/file-source.ts @@ -10,6 +10,8 @@ import { Readable } from 'node:stream'; import { finished } from 'node:stream/promises'; import { resolve } from 'pathe'; +import { Error2, ErrorCodes } from '#/errors'; + export interface ZipSource { readonly stream: Readable; readonly size: number; @@ -31,9 +33,17 @@ export async function openZipSource(source: string, signal?: AbortSignal): Promi try { signal?.throwIfAborted(); const file = await handle.stat({ bigint: true }); - if (!file.isFile()) throw new Error(`not a file: ${source}`); + if (!file.isFile()) { + throw new Error2(ErrorCodes.FS_IS_DIRECTORY, `not a file: ${source}`, { + details: { path: source }, + }); + } const size = Number(file.size); - if (!Number.isSafeInteger(size)) throw new Error(`file is too large to export: ${source}`); + if (!Number.isSafeInteger(size)) { + throw new Error2(ErrorCodes.SESSION_EXPORT_TOO_LARGE, `file is too large to export: ${source}`, { + details: { path: source }, + }); + } signal?.throwIfAborted(); stream = size === 0 diff --git a/packages/agent-core-v2/src/app/skillCatalog/errors.ts b/packages/agent-core-v2/src/app/skillCatalog/errors.ts index 601cf2177cb..fda774c4c50 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/errors.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/errors.ts @@ -9,6 +9,8 @@ export const SkillErrors = { SKILL_NOT_FOUND: 'skill.not_found', SKILL_TYPE_UNSUPPORTED: 'skill.type_unsupported', SKILL_NAME_EMPTY: 'skill.name_empty', + SKILL_PARSE_FAILED: 'skill.parse_failed', + SKILL_NESTED_TOO_DEEP: 'skill.nested_too_deep', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/app/skillCatalog/parser.ts b/packages/agent-core-v2/src/app/skillCatalog/parser.ts index 317ac100a8b..3a99a4a525b 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/parser.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/parser.ts @@ -8,27 +8,31 @@ import path from 'pathe'; +import { Error2 } from '#/_base/errors/errors'; import { FrontmatterError, parseFrontmatter } from '#/_base/text/frontmatter'; +import { SkillErrors } from './errors'; import type { SkillDefinition, SkillMetadata, SkillSource } from './types'; import { isSupportedSkillType } from './types'; -export class SkillParseError extends Error { +export class SkillParseError extends Error2 { readonly reason?: unknown; constructor(message: string, cause?: unknown) { - super(message); + super(SkillErrors.codes.SKILL_PARSE_FAILED, message, { cause }); this.name = 'SkillParseError'; if (cause !== undefined) this.reason = cause; } } -export class UnsupportedSkillTypeError extends Error { +export class UnsupportedSkillTypeError extends Error2 { readonly skillType: string; constructor(skillType: string) { super( + SkillErrors.codes.SKILL_TYPE_UNSUPPORTED, `Skill type "${skillType}" is not supported; only "prompt", "inline", and "flow" are supported.`, + { details: { skillType } }, ); this.name = 'UnsupportedSkillTypeError'; this.skillType = skillType; diff --git a/packages/agent-core-v2/src/app/web/errors.ts b/packages/agent-core-v2/src/app/web/errors.ts new file mode 100644 index 00000000000..ad66e25aaf2 --- /dev/null +++ b/packages/agent-core-v2/src/app/web/errors.ts @@ -0,0 +1,16 @@ +/** + * `web` domain error codes — URL fetching and SSRF guard failures. + */ + +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const WebErrors = { + codes: { + WEB_INVALID_URL: 'web.invalid_url', + WEB_PRIVATE_ADDRESS: 'web.private_address', + WEB_FETCH_FAILED: 'web.fetch_failed', + }, + retryable: ['web.fetch_failed'], +} as const satisfies ErrorDomain; + +registerErrorDomain(WebErrors); diff --git a/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts b/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts index 8c01fce0e18..403f2efe049 100644 --- a/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts @@ -21,6 +21,7 @@ import { parseHTML as rawParseHTML } from 'linkedom'; import { Agent, type Dispatcher } from 'undici'; import { isProxyConfigured, makeNoProxyMatcher, resolveNoProxy } from '#/_base/utils/proxy'; +import { Error2, ErrorCodes } from '#/errors'; import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url-types'; @@ -103,8 +104,10 @@ export class LocalFetchURLProvider implements UrlFetcher { if (Number.isFinite(cl) && cl > this.maxBytes) { await response.body?.cancel().catch(() => { }); - throw new Error( + throw new Error2( + ErrorCodes.WEB_FETCH_FAILED, `Response body too large: ${String(cl)} bytes exceeds maxBytes (${String(this.maxBytes)}).`, + { details: { bytes: cl, maxBytes: this.maxBytes } }, ); } } @@ -113,8 +116,10 @@ export class LocalFetchURLProvider implements UrlFetcher { const actualBytes = Buffer.byteLength(body, 'utf8'); if (actualBytes > this.maxBytes) { - throw new Error( + throw new Error2( + ErrorCodes.WEB_FETCH_FAILED, `Response body too large: ${String(actualBytes)} bytes exceeds maxBytes (${String(this.maxBytes)}).`, + { details: { bytes: actualBytes, maxBytes: this.maxBytes } }, ); } @@ -148,8 +153,10 @@ export class LocalFetchURLProvider implements UrlFetcher { await response.body?.cancel().catch(() => { }); if (redirects >= MAX_REDIRECT_HOPS) { - throw new Error( + throw new Error2( + ErrorCodes.WEB_FETCH_FAILED, `Too many redirects while fetching "${url}" (limit ${String(MAX_REDIRECT_HOPS)}).`, + { details: { url, limit: MAX_REDIRECT_HOPS } }, ); } redirects += 1; @@ -201,7 +208,8 @@ export class LocalFetchURLProvider implements UrlFetcher { const fallbackText = (container?.textContent ?? '').trim(); if (fallbackText.length === 0) { - throw new Error( + throw new Error2( + ErrorCodes.WEB_FETCH_FAILED, 'Failed to extract meaningful content from the page. The page may require JavaScript to render.', ); } @@ -243,10 +251,14 @@ async function resolveSafeFetchTarget(url: string, allowPrivate: boolean): Promi try { parsed = new URL(url); } catch { - throw new Error(`Invalid URL: "${url}"`); + throw new Error2(ErrorCodes.WEB_INVALID_URL, `Invalid URL: "${url}"`, { details: { url } }); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new Error(`Unsupported URL scheme "${parsed.protocol}" — only http(s) allowed.`); + throw new Error2( + ErrorCodes.WEB_INVALID_URL, + `Unsupported URL scheme "${parsed.protocol}" — only http(s) allowed.`, + { details: { url, protocol: parsed.protocol } }, + ); } const hostRaw = parsed.hostname.toLowerCase(); const host = hostRaw.startsWith('[') && hostRaw.endsWith(']') ? hostRaw.slice(1, -1) : hostRaw; @@ -254,25 +266,35 @@ async function resolveSafeFetchTarget(url: string, allowPrivate: boolean): Promi if (allowPrivate) return { host, port }; if (isIP(host) !== 0) { if (isBlockedAddress(host)) { - throw new Error(`Refusing to fetch private address: "${host}"`); + throw new Error2(ErrorCodes.WEB_PRIVATE_ADDRESS, `Refusing to fetch private address: "${host}"`, { + details: { host }, + }); } return { host, port }; } if (host === 'localhost' || host.endsWith('.localhost')) { - throw new Error(`Refusing to fetch private host: "${host}"`); + throw new Error2(ErrorCodes.WEB_PRIVATE_ADDRESS, `Refusing to fetch private host: "${host}"`, { + details: { host }, + }); } let addresses: LookupAddress[]; try { addresses = await lookup(host, { all: true }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Cannot resolve host "${host}" for the fetch safety check: ${detail}`, { - cause: error, - }); + throw new Error2( + ErrorCodes.WEB_PRIVATE_ADDRESS, + `Cannot resolve host "${host}" for the fetch safety check: ${detail}`, + { cause: error, details: { host } }, + ); } for (const { address } of addresses) { if (isBlockedAddress(address)) { - throw new Error(`Refusing to fetch host "${host}": resolves to private address "${address}".`); + throw new Error2( + ErrorCodes.WEB_PRIVATE_ADDRESS, + `Refusing to fetch host "${host}": resolves to private address "${address}".`, + { details: { host, address } }, + ); } } return { host, port, addresses }; diff --git a/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts b/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts index 5f69d2fc3e3..f992c267a7f 100644 --- a/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts @@ -1,3 +1,5 @@ +import { Error2, ErrorCodes } from '#/errors'; + import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url-types'; interface BearerTokenProvider { @@ -103,6 +105,9 @@ export class MoonshotFetchURLProvider implements UrlFetcher { } } if (this.apiKey !== undefined && this.apiKey.length > 0) return this.apiKey; - throw new Error('Moonshot fetch service is not configured: missing API key or token provider.'); + throw new Error2( + ErrorCodes.AUTH_TOKEN_MISSING, + 'Moonshot fetch service is not configured: missing API key or token provider.', + ); } } diff --git a/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts b/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts index 1d1f2eb80b9..3750c03eac8 100644 --- a/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts +++ b/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts @@ -2,6 +2,10 @@ * `web` domain — host-injected `UrlFetcher` contract. */ +import { Error2 } from '#/_base/errors/errors'; + +import { WebErrors } from '../errors'; + /** * How the returned content relates to the original response body. * @@ -24,11 +28,11 @@ export interface UrlFetcher { ): Promise; } -export class HttpFetchError extends Error { +export class HttpFetchError extends Error2 { override readonly name = 'HttpFetchError'; readonly status: number; constructor(status: number, message: string) { - super(message); + super(WebErrors.codes.WEB_FETCH_FAILED, message, { details: { status } }); this.status = status; } } diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index 0f002c69e11..5ecfd33a549 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -10,6 +10,7 @@ import { AuthErrors } from '#/app/auth/errors'; import { TaskErrors } from '#/agent/task/errors'; import { ProtocolErrors } from '#/kosong/protocol/errors'; import { ConfigErrors } from '#/app/config/errors'; +import { CronErrors } from '#/app/cron/errors'; import { FileErrors } from '#/app/file/fileService'; import { FsErrors } from '#/workspace/workspaceFs/internal/errors'; import { FullCompactionErrors } from '#/agent/fullCompaction/errors'; @@ -30,6 +31,7 @@ import { SkillErrors } from '#/app/skillCatalog/errors'; import { StorageErrors } from '#/persistence/interface/storage'; import { TerminalErrors } from '#/os/interface/terminalErrors'; import { UsageErrors } from '#/agent/usage/errors'; +import { WebErrors } from '#/app/web/errors'; import { WireErrors } from '#/wire/errors'; import { WorkspaceErrors } from '#/app/workspace/errors'; @@ -43,6 +45,7 @@ export { AuthErrors } from '#/app/auth/errors'; export { TaskErrors } from '#/agent/task/errors'; export { ProtocolErrors } from '#/kosong/protocol/errors'; export { ConfigErrors } from '#/app/config/errors'; +export { CronErrors } from '#/app/cron/errors'; export { FileErrors } from '#/app/file/fileService'; export { FsErrors } from '#/workspace/workspaceFs/internal/errors'; export { FullCompactionErrors } from '#/agent/fullCompaction/errors'; @@ -63,6 +66,7 @@ export { SkillErrors } from '#/app/skillCatalog/errors'; export { StorageErrors } from '#/persistence/interface/storage'; export { TerminalErrors } from '#/os/interface/terminalErrors'; export { UsageErrors } from '#/agent/usage/errors'; +export { WebErrors } from '#/app/web/errors'; export { WireErrors } from '#/wire/errors'; export { WorkspaceErrors } from '#/app/workspace/errors'; @@ -73,6 +77,7 @@ export const ErrorCodes = { ...TaskErrors.codes, ...ProtocolErrors.codes, ...ConfigErrors.codes, + ...CronErrors.codes, ...FileErrors.codes, ...FsErrors.codes, ...FullCompactionErrors.codes, @@ -93,6 +98,7 @@ export const ErrorCodes = { ...StorageErrors.codes, ...TerminalErrors.codes, ...UsageErrors.codes, + ...WebErrors.codes, ...WireErrors.codes, ...WorkspaceErrors.codes, } as const; diff --git a/packages/agent-core-v2/src/hooks.ts b/packages/agent-core-v2/src/hooks.ts index 24b024940a9..dd97aede16d 100644 --- a/packages/agent-core-v2/src/hooks.ts +++ b/packages/agent-core-v2/src/hooks.ts @@ -5,6 +5,7 @@ * forks. Bound as utility infrastructure, not a scoped Service. */ import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; +import { BugIndicatingError } from "#/errors"; export type Hooks> = { readonly [K in keyof TEvents]: HookSlot; @@ -46,7 +47,7 @@ export class OrderedHookSlot implements HookSlot { options: HookRegisterOptions = {}, ): IDisposable { if (options.before !== undefined && options.after !== undefined) { - throw new Error('Hook registration cannot specify both before and after'); + throw new BugIndicatingError('Hook registration cannot specify both before and after'); } this.delete(id); @@ -59,7 +60,7 @@ export class OrderedHookSlot implements HookSlot { const targetIndex = this.entries.findIndex((item) => item.id === target); if (targetIndex < 0) { - throw new Error(`Hook target "${target}" is not registered`); + throw new BugIndicatingError(`Hook target "${target}" is not registered`); } const insertAt = options.before !== undefined ? targetIndex : targetIndex + 1; diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index d00b1efde88..bea23797a8f 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -9,6 +9,13 @@ * by-design capability gap (provider has no video upload hook) so callers * can tell it apart from an upload that failed at runtime. * + * The family is born-coded: every class extends `Error2` and computes its + * wire code (`provider.*` / `context.overflow`) at construction from the + * status code / finish reason, so no boundary translation is needed — the + * code string constants live here (the L0 wire contract) and are registered + * by `kosong/protocol/errors.ts` (`ProtocolErrors`). `translateProviderError` + * only remains as the abort guard and the foreign-error fallback. + * * Abort has exactly one standard shape here: the DOMException built by * `createAbortError`. Provider error converters must run the `throwIfAbortError` * guard FIRST in their classification chain — a user cancellation is thrown @@ -16,20 +23,55 @@ * a retryable provider error. */ +import { Error2, type Error2Options } from '#/_base/errors/errors'; import type { FinishReason } from './provider'; export const CONFIG_INVALID_ERROR_CODE = 'config.invalid'; -export class ChatProviderError extends Error { - constructor(message: string) { - super(message); - this.name = 'ChatProviderError'; +export const PROVIDER_API_ERROR_CODE = 'provider.api_error'; +export const PROVIDER_FILTERED_ERROR_CODE = 'provider.filtered'; +export const PROVIDER_RATE_LIMIT_ERROR_CODE = 'provider.rate_limit'; +export const PROVIDER_AUTH_ERROR_CODE = 'provider.auth_error'; +export const PROVIDER_CONNECTION_ERROR_CODE = 'provider.connection_error'; +export const PROVIDER_OVERLOADED_ERROR_CODE = 'provider.overloaded'; +export const CONTEXT_OVERFLOW_ERROR_CODE = 'context.overflow'; + +export type ProviderErrorCode = + | typeof PROVIDER_API_ERROR_CODE + | typeof PROVIDER_FILTERED_ERROR_CODE + | typeof PROVIDER_RATE_LIMIT_ERROR_CODE + | typeof PROVIDER_AUTH_ERROR_CODE + | typeof PROVIDER_CONNECTION_ERROR_CODE + | typeof PROVIDER_OVERLOADED_ERROR_CODE + | typeof CONTEXT_OVERFLOW_ERROR_CODE; + +export function sanitizeStatusErrorMessage(message: string): string { + const titleMatch = /]*>([\s\S]*?)<\/title>/i.exec(message); + const extracted = titleMatch?.[1]?.trim(); + const normalized = extracted !== undefined && extracted.length > 0 ? extracted : message; + return normalized.replaceAll('\r', ''); +} + +function codeForStatusError(statusCode: number): ProviderErrorCode { + if (statusCode === 429) return PROVIDER_RATE_LIMIT_ERROR_CODE; + if (statusCode === 401 || statusCode === 403) return PROVIDER_AUTH_ERROR_CODE; + if (statusCode === 529) return PROVIDER_OVERLOADED_ERROR_CODE; + return PROVIDER_API_ERROR_CODE; +} + +export class ChatProviderError extends Error2 { + constructor( + message: string, + code: ProviderErrorCode = PROVIDER_API_ERROR_CODE, + options?: Error2Options, + ) { + super(code, message, { ...options, name: 'ChatProviderError' }); } } export class APIConnectionError extends ChatProviderError { constructor(message: string) { - super(message); + super(message, PROVIDER_CONNECTION_ERROR_CODE); this.name = 'APIConnectionError'; } } @@ -43,7 +85,7 @@ export class VideoUploadUnsupportedError extends ChatProviderError { export class APITimeoutError extends ChatProviderError { constructor(message: string) { - super(message); + super(message, PROVIDER_CONNECTION_ERROR_CODE); this.name = 'APITimeoutError'; } } @@ -60,8 +102,11 @@ export class APIStatusError extends ChatProviderError { requestId?: string | null, retryAfterMs?: number | null, traceId?: string | null, + code: ProviderErrorCode = codeForStatusError(statusCode), ) { - super(message); + super(sanitizeStatusErrorMessage(message), code, { + details: { statusCode, requestId: requestId ?? null, traceId: traceId ?? null }, + }); this.name = 'APIStatusError'; this.statusCode = statusCode; this.requestId = requestId ?? null; @@ -78,7 +123,7 @@ export class APIContextOverflowError extends APIStatusError { retryAfterMs?: number | null, traceId?: string | null, ) { - super(statusCode, message, requestId, retryAfterMs, traceId); + super(statusCode, message, requestId, retryAfterMs, traceId, CONTEXT_OVERFLOW_ERROR_CODE); this.name = 'APIContextOverflowError'; } } @@ -115,7 +160,7 @@ export class APIProviderQuotaExhaustedError extends APIStatusError { retryAfterMs?: number | null, traceId?: string | null, ) { - super(429, message, requestId, retryAfterMs, traceId); + super(429, message, requestId, retryAfterMs, traceId, PROVIDER_API_ERROR_CODE); this.name = 'APIProviderQuotaExhaustedError'; } } @@ -128,7 +173,7 @@ export class APIProviderOverloadedError extends APIStatusError { retryAfterMs?: number | null, traceId?: string | null, ) { - super(statusCode, message, requestId, retryAfterMs, traceId); + super(statusCode, message, requestId, retryAfterMs, traceId, PROVIDER_OVERLOADED_ERROR_CODE); this.name = 'APIProviderOverloadedError'; } } @@ -144,10 +189,16 @@ export class APIEmptyResponseError extends ChatProviderError { readonly rawFinishReason?: string | null; } = {}, ) { - super(message); + const finishReason = options.finishReason ?? null; + const rawFinishReason = options.rawFinishReason ?? null; + super( + message, + finishReason === 'filtered' ? PROVIDER_FILTERED_ERROR_CODE : PROVIDER_API_ERROR_CODE, + { details: { finishReason, rawFinishReason } }, + ); this.name = 'APIEmptyResponseError'; - this.finishReason = options.finishReason ?? null; - this.rawFinishReason = options.rawFinishReason ?? null; + this.finishReason = finishReason; + this.rawFinishReason = rawFinishReason; } } diff --git a/packages/agent-core-v2/src/kosong/model/inspection.ts b/packages/agent-core-v2/src/kosong/model/inspection.ts index fd3b0da9644..bf94aae2279 100644 --- a/packages/agent-core-v2/src/kosong/model/inspection.ts +++ b/packages/agent-core-v2/src/kosong/model/inspection.ts @@ -19,6 +19,8 @@ import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; +import { BugIndicatingError } from '#/_base/errors/errors'; + import type { ModelCapability } from '#/kosong/contract/capability'; import type { InspectionSource, ResolutionTrace } from '#/kosong/contract/inspection'; import type { Protocol, ProtocolProviderOptions } from '#/kosong/protocol/protocol'; @@ -501,7 +503,7 @@ function attributeHeaders( function required(trace: ResolutionTraceCollector, key: string, what: string): T { const value = trace.captured(key); if (value === undefined) { - throw new Error(`resolution trace is missing the ${what} capture ('${key}')`); + throw new BugIndicatingError(`resolution trace is missing the ${what} capture ('${key}')`); } return value; } diff --git a/packages/agent-core-v2/src/kosong/protocol/errors.ts b/packages/agent-core-v2/src/kosong/protocol/errors.ts index e237def8932..5e3c2c0e1cd 100644 --- a/packages/agent-core-v2/src/kosong/protocol/errors.ts +++ b/packages/agent-core-v2/src/kosong/protocol/errors.ts @@ -2,11 +2,16 @@ * `kosong/protocol` domain — wire API failure codes and the boundary * translation from raw contract errors to coded `Error2`s. * - * `translateProviderError` converts the L0 `API*Error` family into coded - * errors callers can branch on across the wire. Its FIRST guard is the - * contract's `throwIfAbortError`: a user cancellation is thrown as the - * standard abort DOMException and can never be misclassified as a retryable - * provider failure. The guard throws rather than returns, by design. + * The `ChatProviderError` family is born-coded (see `kosong/contract/errors`): + * every instance already carries its wire code, so `translateProviderError`'s + * `isError2` guard passes it through untouched. What remains here is the + * abort guard and the fallback for errors foreign to the family (plain + * `Error` / unknown thrown values → `internal`). + * + * `translateProviderError`'s FIRST guard is the contract's + * `throwIfAbortError`: a user cancellation is thrown as the standard abort + * DOMException and can never be misclassified as a retryable provider + * failure. The guard throws rather than returns, by design. * * Side-effect module: importing registers the error domain. */ @@ -14,26 +19,27 @@ import { CoreErrors, registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; import { Error2, isError2 } from '#/_base/errors/errors'; import { - APIConnectionError, - APIContextOverflowError, - APIEmptyResponseError, - APIProviderOverloadedError, - APIProviderQuotaExhaustedError, - APIStatusError, - APITimeoutError, - ChatProviderError, + CONTEXT_OVERFLOW_ERROR_CODE, + PROVIDER_API_ERROR_CODE, + PROVIDER_AUTH_ERROR_CODE, + PROVIDER_CONNECTION_ERROR_CODE, + PROVIDER_FILTERED_ERROR_CODE, + PROVIDER_OVERLOADED_ERROR_CODE, + PROVIDER_RATE_LIMIT_ERROR_CODE, throwIfAbortError, } from '#/kosong/contract/errors'; +export { sanitizeStatusErrorMessage } from '#/kosong/contract/errors'; + export const ProtocolErrors = { codes: { - PROVIDER_API_ERROR: 'provider.api_error', - PROVIDER_FILTERED: 'provider.filtered', - PROVIDER_RATE_LIMIT: 'provider.rate_limit', - PROVIDER_AUTH_ERROR: 'provider.auth_error', - PROVIDER_CONNECTION_ERROR: 'provider.connection_error', - PROVIDER_OVERLOADED: 'provider.overloaded', - CONTEXT_OVERFLOW: 'context.overflow', + PROVIDER_API_ERROR: PROVIDER_API_ERROR_CODE, + PROVIDER_FILTERED: PROVIDER_FILTERED_ERROR_CODE, + PROVIDER_RATE_LIMIT: PROVIDER_RATE_LIMIT_ERROR_CODE, + PROVIDER_AUTH_ERROR: PROVIDER_AUTH_ERROR_CODE, + PROVIDER_CONNECTION_ERROR: PROVIDER_CONNECTION_ERROR_CODE, + PROVIDER_OVERLOADED: PROVIDER_OVERLOADED_ERROR_CODE, + CONTEXT_OVERFLOW: CONTEXT_OVERFLOW_ERROR_CODE, }, retryable: [ 'provider.rate_limit', @@ -82,55 +88,6 @@ export function translateProviderError(error: unknown): Error2 { if (isError2(error)) { return error; } - if (error instanceof APIStatusError) { - const code = - error instanceof APIContextOverflowError - ? ProtocolErrors.codes.CONTEXT_OVERFLOW - : error instanceof APIProviderOverloadedError || error.statusCode === 529 - ? ProtocolErrors.codes.PROVIDER_OVERLOADED - : error instanceof APIProviderQuotaExhaustedError - ? ProtocolErrors.codes.PROVIDER_API_ERROR - : error.statusCode === 429 - ? ProtocolErrors.codes.PROVIDER_RATE_LIMIT - : error.statusCode === 401 || error.statusCode === 403 - ? ProtocolErrors.codes.PROVIDER_AUTH_ERROR - : ProtocolErrors.codes.PROVIDER_API_ERROR; - return new Error2(code, sanitizeStatusErrorMessage(error.message), { - name: error.name, - cause: error, - details: { - statusCode: error.statusCode, - requestId: error.requestId, - traceId: error.traceId, - }, - }); - } - if (error instanceof APIConnectionError || error instanceof APITimeoutError) { - return new Error2(ProtocolErrors.codes.PROVIDER_CONNECTION_ERROR, error.message, { - name: error.name, - cause: error, - }); - } - if (error instanceof APIEmptyResponseError) { - const code = - error.finishReason === 'filtered' - ? ProtocolErrors.codes.PROVIDER_FILTERED - : ProtocolErrors.codes.PROVIDER_API_ERROR; - return new Error2(code, error.message, { - name: error.name, - cause: error, - details: { - finishReason: error.finishReason, - rawFinishReason: error.rawFinishReason, - }, - }); - } - if (error instanceof ChatProviderError) { - return new Error2(ProtocolErrors.codes.PROVIDER_API_ERROR, error.message, { - name: error.name, - cause: error, - }); - } if (error instanceof Error) { return new Error2(CoreErrors.codes.INTERNAL, error.message, { name: error.name, @@ -139,10 +96,3 @@ export function translateProviderError(error: unknown): Error2 { } return new Error2(CoreErrors.codes.INTERNAL, String(error), { cause: error }); } - -export function sanitizeStatusErrorMessage(message: string): string { - const titleMatch = /]*>([\s\S]*?)<\/title>/i.exec(message); - const extracted = titleMatch?.[1]?.trim(); - const normalized = extracted !== undefined && extracted.length > 0 ? extracted : message; - return normalized.replaceAll('\r', ''); -} diff --git a/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts b/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts index 06aea9af72e..72146fb0539 100644 --- a/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts +++ b/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts @@ -11,6 +11,7 @@ * deliberately registers nothing on its own. */ +import { BugIndicatingError } from '#/_base/errors/errors'; import type { ModelCapability } from '#/kosong/contract/capability'; import type { ChatProvider } from '#/kosong/contract/provider'; @@ -39,7 +40,7 @@ const protocolBases = new Map(); export function registerProtocolBase(definition: ProtocolBaseDefinition): void { if (protocolBases.has(definition.id)) { - throw new Error(`protocol base '${definition.id}' is already registered`); + throw new BugIndicatingError(`protocol base '${definition.id}' is already registered`); } protocolBases.set(definition.id, definition); } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts index f712d6c5e5a..ac3ddeea1f1 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts @@ -25,6 +25,7 @@ import { OpenAIError, } from 'openai'; +import { BugIndicatingError } from '#/_base/errors/errors'; import { APIConnectionError, APIProviderQuotaExhaustedError, @@ -81,7 +82,7 @@ export function convertContentPart(part: ContentPart): OpenAIContentPart | null : { url: part.videoUrl.url, id: part.videoUrl.id }, }; default: - throw new Error(`Unknown content part type: ${(part as ContentPart).type}`); + throw new BugIndicatingError(`Unknown content part type: ${(part as ContentPart).type}`); } } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index 0018ad2179e..89e3219de83 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -15,6 +15,7 @@ import OpenAI from 'openai'; +import { Error2 } from '#/_base/errors/errors'; import { APIContextOverflowError, APIProviderQuotaExhaustedError, @@ -41,6 +42,7 @@ import type { } from '#/kosong/contract/provider'; import type { Tool } from '#/kosong/contract/tool'; import type { TokenUsage } from '#/kosong/contract/usage'; +import { ProtocolErrors } from '#/kosong/protocol/errors'; import { convertOpenAIError, @@ -1170,7 +1172,8 @@ export class OpenAIResponsesChatProvider implements ChatProvider { !('responses' in client) || typeof (client as { responses?: { create?: unknown } }).responses?.create !== 'function' ) { - throw new Error( + throw new Error2( + ProtocolErrors.codes.PROVIDER_API_ERROR, 'OpenAI SDK version does not support Responses API. Upgrade to >=4.x with responses support.', ); } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts b/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts index ca16143da6b..5a059a544bf 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts @@ -6,6 +6,7 @@ * `toolCallId` pair consistently and keeps rewritten ids unique. */ +import { BugIndicatingError } from '#/_base/errors/errors'; import type { Message, ToolCall } from '#/kosong/contract/message'; import type { ToolCallIdPolicy } from '#/kosong/contract/provider'; @@ -123,7 +124,9 @@ function truncateToolCallId(base: string, maxLength: number | undefined, suffix: if (maxLength === undefined) return `${base}${suffix}`; const baseLength = maxLength - suffix.length; if (baseLength <= 0) { - throw new Error(`Tool call id maxLength ${maxLength} is too small for suffix ${suffix}.`); + throw new BugIndicatingError( + `Tool call id maxLength ${maxLength} is too small for suffix ${suffix}.`, + ); } return `${base.slice(0, baseLength)}${suffix}`; } diff --git a/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts b/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts index 55e4ae28b2d..2e9f99ccaeb 100644 --- a/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts +++ b/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts @@ -17,6 +17,7 @@ * bag (defaulting to `process.env`). */ +import { BugIndicatingError } from '#/_base/errors/errors'; import type { Protocol, ProtocolAdapterConfig } from '#/kosong/protocol/protocol'; import type { ProtocolEndpoint, @@ -44,7 +45,7 @@ export function registerProviderDefinition(definition: ProviderDefinition): void providerDefinitions.set(definition.id, byProtocol); } if (byProtocol.has(definition.baseProtocol)) { - throw new Error( + throw new BugIndicatingError( `provider definition '${definition.id}' is already registered for protocol '${definition.baseProtocol}'`, ); } diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts index 5cd85c91cdf..acc7645befd 100644 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts +++ b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts @@ -10,6 +10,9 @@ * remaining local `$ref` pointers stay resolvable to a JSON Schema validator. */ +import { Error2 } from '#/_base/errors/errors'; +import { ProtocolErrors } from '#/kosong/protocol/errors'; + export function derefJsonSchema(schema: Record): Record { const visited = new Set(); const result = resolveNode(schema, schema, visited) as Record; @@ -111,7 +114,10 @@ export function normalizeKimiToolSchema(schema: Record): Record function ensureKimiPropertyTypes(schema: Record): Record { const normalized = cloneJsonValue(schema); if (!isRecord(normalized)) { - throw new Error('JSON Schema root must normalize to an object.'); + throw new Error2( + ProtocolErrors.codes.PROVIDER_API_ERROR, + 'JSON Schema root must normalize to an object.', + ); } recurseSchema(normalized); return normalized; @@ -345,7 +351,10 @@ function inferTypeFromValues(values: unknown[]): JsonSchemaType { for (const value of values) { const valueType = inferValueType(value); if (valueType === undefined) { - throw new Error('Cannot infer JSON Schema type from non-JSON enum or const value.'); + throw new Error2( + ProtocolErrors.codes.PROVIDER_API_ERROR, + 'Cannot infer JSON Schema type from non-JSON enum or const value.', + ); } inferred.add(valueType); } @@ -353,11 +362,17 @@ function inferTypeFromValues(values: unknown[]): JsonSchemaType { if (types.length === 1) { const onlyType = types[0]; if (onlyType === undefined) { - throw new Error('Cannot infer JSON Schema type from an empty enum.'); + throw new Error2( + ProtocolErrors.codes.PROVIDER_API_ERROR, + 'Cannot infer JSON Schema type from an empty enum.', + ); } return onlyType; } - throw new Error('Mixed JSON Schema enum or const types are not supported by Kimi tool schemas.'); + throw new Error2( + ProtocolErrors.codes.PROVIDER_API_ERROR, + 'Mixed JSON Schema enum or const types are not supported by Kimi tool schemas.', + ); } function inferValueType(value: unknown): JsonSchemaType | undefined { diff --git a/packages/agent-core-v2/src/mcpCore/client-http.ts b/packages/agent-core-v2/src/mcpCore/client-http.ts index 04b42d499e8..91971685e48 100644 --- a/packages/agent-core-v2/src/mcpCore/client-http.ts +++ b/packages/agent-core-v2/src/mcpCore/client-http.ts @@ -2,6 +2,7 @@ * `mcpCore` domain — Streamable HTTP transport MCP client. */ +import { ErrorCodes, Error2 } from '#/errors'; import type { McpServerHttpConfig } from './config-schema'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; @@ -63,7 +64,7 @@ export class HttpMcpClient implements MCPClient { async connect(): Promise { if (this.closed) { - throw new Error('MCP HTTP client is closed'); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP HTTP client is closed'); } if (this.started) return; this.started = true; @@ -79,7 +80,7 @@ export class HttpMcpClient implements MCPClient { } if (this.closed) { await this.closeStartedClient(); - throw new Error('MCP HTTP client was closed during startup'); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP HTTP client was closed during startup'); } this.ready = true; } diff --git a/packages/agent-core-v2/src/mcpCore/client-sse.ts b/packages/agent-core-v2/src/mcpCore/client-sse.ts index 3da3bab6f37..0084e4b31f3 100644 --- a/packages/agent-core-v2/src/mcpCore/client-sse.ts +++ b/packages/agent-core-v2/src/mcpCore/client-sse.ts @@ -2,6 +2,7 @@ * `mcpCore` domain — SSE transport MCP client. */ +import { ErrorCodes, Error2 } from '#/errors'; import type { McpServerSseConfig } from './config-schema'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; @@ -63,7 +64,7 @@ export class SseMcpClient implements MCPClient { async connect(): Promise { if (this.closed) { - throw new Error('MCP SSE client is closed'); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP SSE client is closed'); } if (this.started) return; this.started = true; @@ -79,7 +80,7 @@ export class SseMcpClient implements MCPClient { } if (this.closed) { await this.closeStartedClient(); - throw new Error('MCP SSE client was closed during startup'); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP SSE client was closed during startup'); } this.ready = true; } diff --git a/packages/agent-core-v2/src/mcpCore/client-stdio.ts b/packages/agent-core-v2/src/mcpCore/client-stdio.ts index 489c365df33..7f81f396458 100644 --- a/packages/agent-core-v2/src/mcpCore/client-stdio.ts +++ b/packages/agent-core-v2/src/mcpCore/client-stdio.ts @@ -71,7 +71,7 @@ export class StdioMcpClient implements MCPClient { async connect(): Promise { if (this.closed) { - throw new Error('MCP stdio client is closed'); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP stdio client is closed'); } if (this.started) return; this.started = true; @@ -87,7 +87,7 @@ export class StdioMcpClient implements MCPClient { } if (this.closed) { await this.closeStartedClient(); - throw new Error('MCP stdio client was closed during startup'); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP stdio client was closed during startup'); } this.ready = true; } diff --git a/packages/agent-core-v2/src/mcpCore/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts index 78940ac5430..e483b1cc8a0 100644 --- a/packages/agent-core-v2/src/mcpCore/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -511,7 +511,7 @@ async function withTimeout( return await new Promise((resolve, reject) => { timer = setTimeout(() => { onTimeout?.(); - reject(new Error(`Timed out after ${timeoutMs}ms`)); + reject(new Error2(ErrorCodes.MCP_STARTUP_FAILED, `Timed out after ${timeoutMs}ms`)); }, timeoutMs); promise.then(resolve, reject); }); diff --git a/packages/agent-core-v2/src/mcpCore/errors.ts b/packages/agent-core-v2/src/mcpCore/errors.ts index 0511869d832..d8d32bd2d00 100644 --- a/packages/agent-core-v2/src/mcpCore/errors.ts +++ b/packages/agent-core-v2/src/mcpCore/errors.ts @@ -10,6 +10,7 @@ export const McpErrors = { MCP_SERVER_DISABLED: 'mcp.server_disabled', MCP_STARTUP_FAILED: 'mcp.startup_failed', MCP_TOOL_NAME_COLLISION: 'mcp.tool_name_collision', + MCP_OAUTH_FAILED: 'mcp.oauth_failed', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/mcpCore/oauth/provider.ts b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts index 206d704ed87..545b7dfa580 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/provider.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts @@ -17,6 +17,8 @@ import { randomBytes } from 'node:crypto'; +import { BugIndicatingError } from '#/errors'; + import type { OAuthClientProvider, OAuthDiscoveryState, @@ -146,7 +148,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider { codeVerifier(): string { if (this._codeVerifier === undefined) { - throw new Error('McpOAuthClientProvider: PKCE code verifier not initialized'); + throw new BugIndicatingError('McpOAuthClientProvider: PKCE code verifier not initialized'); } return this._codeVerifier; } diff --git a/packages/agent-core-v2/src/mcpCore/oauth/service.ts b/packages/agent-core-v2/src/mcpCore/oauth/service.ts index 3b8c0f41f43..b4a25d9d573 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/service.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/service.ts @@ -24,6 +24,8 @@ import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { ErrorCodes, Error2, isError2 } from '#/errors'; + import { startCallbackServer, type CallbackServer } from './callback-server'; import { McpOAuthClientProvider } from './provider'; import { mcpOAuthStoreKey, type McpOAuthStore } from './store'; @@ -110,7 +112,10 @@ export class McpOAuthService { } authorizationUrl = provider.takeAuthorizationUrl(); if (authorizationUrl === undefined) { - throw new Error('OAuth provider did not capture an authorization URL'); + throw new Error2( + ErrorCodes.MCP_OAUTH_FAILED, + 'OAuth provider did not capture an authorization URL', + ); } } catch (error) { await callbackServer.close().catch(() => undefined); @@ -129,7 +134,7 @@ export class McpOAuthService { const complete: BeginAuthorizationResult['complete'] = async (opts = {}) => { if (settled) { - throw new Error('OAuth flow already completed or cancelled'); + throw new Error2(ErrorCodes.MCP_OAUTH_FAILED, 'OAuth flow already completed or cancelled'); } try { const { code, state } = await callbackServer.waitForCode({ @@ -138,14 +143,21 @@ export class McpOAuthService { }); const expectedState = provider.expectedState(); if (expectedState !== undefined && state !== expectedState) { - throw new Error('OAuth state mismatch — possible CSRF; refusing token exchange'); + throw new Error2( + ErrorCodes.MCP_OAUTH_FAILED, + 'OAuth state mismatch — possible CSRF; refusing token exchange', + ); } const finalResult = await auth(provider as OAuthClientProvider, { serverUrl, authorizationCode: code, }); if (finalResult !== 'AUTHORIZED') { - throw new Error(`OAuth code exchange returned "${finalResult}" instead of AUTHORIZED`); + throw new Error2( + ErrorCodes.MCP_OAUTH_FAILED, + `OAuth code exchange returned "${finalResult}" instead of AUTHORIZED`, + { details: { result: finalResult } }, + ); } } catch (error) { await cancel(); @@ -168,18 +180,24 @@ export class McpOAuthService { } } -export class AlreadyAuthorizedError extends Error { +export class AlreadyAuthorizedError extends Error2 { constructor(serverName: string) { - super(`"${serverName}" is already authorized; no browser flow needed`); + super( + ErrorCodes.MCP_OAUTH_FAILED, + `"${serverName}" is already authorized; no browser flow needed`, + ); this.name = 'AlreadyAuthorizedError'; } } -function wrapAuthError(prefix: string, error: unknown): Error { +function wrapAuthError(prefix: string, error: unknown): Error2 { + if (isError2(error)) { + return error; + } if (error instanceof Error) { - const wrapped = new Error(`${prefix}: ${error.message}`); - wrapped.cause = error; - return wrapped; + return new Error2(ErrorCodes.MCP_OAUTH_FAILED, `${prefix}: ${error.message}`, { + cause: error, + }); } - return new Error(`${prefix}: ${String(error)}`); + return new Error2(ErrorCodes.MCP_OAUTH_FAILED, `${prefix}: ${String(error)}`, { cause: error }); } diff --git a/packages/agent-core-v2/src/mcpCore/oauth/store.ts b/packages/agent-core-v2/src/mcpCore/oauth/store.ts index bf7083a57bb..00aee8cfc8e 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/store.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/store.ts @@ -12,10 +12,12 @@ import { createHash } from 'node:crypto'; import { basename } from 'pathe'; +import { ErrorCodes, Error2 } from '#/errors'; + export function sanitizeStoreKey(name: string): string { const safe = basename(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); if (safe.length === 0 || safe.startsWith('.')) { - throw new Error(`Invalid MCP OAuth store key: "${name}"`); + throw new Error2(ErrorCodes.CONFIG_INVALID, `Invalid MCP OAuth store key: "${name}"`); } return safe; } diff --git a/packages/agent-core-v2/src/mcpCore/types.ts b/packages/agent-core-v2/src/mcpCore/types.ts index 2eecfe22fc1..09e9d8c712b 100644 --- a/packages/agent-core-v2/src/mcpCore/types.ts +++ b/packages/agent-core-v2/src/mcpCore/types.ts @@ -6,6 +6,8 @@ * fake transport without pulling in the MCP SDK type graph. */ +import { ErrorCodes, Error2 } from '#/errors'; + /** * Inline resource contents nested under an EmbeddedResource block. * Exactly one of `text` or `blob` is populated, per the MCP schema's @@ -57,5 +59,8 @@ export function assertMcpInputSchema( if (typeof inputSchema === 'object' && inputSchema !== null && !Array.isArray(inputSchema)) { return inputSchema as Record; } - throw new Error(`Invalid inputSchema for MCP tool "${toolName}": schema must be a JSON object`); + throw new Error2( + ErrorCodes.MCP_STARTUP_FAILED, + `Invalid inputSchema for MCP tool "${toolName}": schema must be a JSON object`, + ); } diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts index 5eb1562b9b8..6edac6f0919 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts @@ -20,6 +20,7 @@ import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; import { basename, join } from 'pathe'; import { abortable } from '#/_base/utils/abort'; +import { ErrorCodes, Error2 } from '#/errors'; const RG_VERSION = '15.0.0'; const RG_BASE_URL = 'https://code.kimi.com/kimi-code/rg'; @@ -101,7 +102,7 @@ async function resolveRgPath( if (options.allowCachedFallback === true) { return downloadRgWithLock(probe, shareDir); } - throw new Error('ripgrep (rg) is not available on PATH'); + throw new Error2(ErrorCodes.OS_FS_UNAVAILABLE, 'ripgrep (rg) is not available on PATH'); } export async function findExistingRg( @@ -181,8 +182,10 @@ export function detectTarget(): string | undefined { async function downloadAndInstallRg(shareDir: string): Promise { const target = detectTarget(); if (target === undefined) { - throw new Error( + throw new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, `Unsupported platform/arch for ripgrep download: ${process.platform}/${process.arch}`, + { details: { platform: process.platform, arch: process.arch } }, ); } @@ -191,7 +194,11 @@ async function downloadAndInstallRg(shareDir: string): Promise { const archiveName = `ripgrep-${RG_VERSION}-${target}.${archiveExt}`; const expectedSha256 = RG_ARCHIVE_SHA256[archiveName]; if (expectedSha256 === undefined) { - throw new Error(`No pinned SHA-256 is configured for ripgrep archive ${archiveName}`); + throw new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, + `No pinned SHA-256 is configured for ripgrep archive ${archiveName}`, + { details: { archiveName } }, + ); } const url = `${RG_BASE_URL}/${archiveName}`; @@ -214,7 +221,11 @@ async function downloadAndInstallRg(shareDir: string): Promise { clearTimeout(timeoutHandle); } if (!resp.ok || resp.body === null) { - throw new Error(`Failed to download ripgrep: HTTP ${String(resp.status)} ${resp.statusText}`); + throw new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, + `Failed to download ripgrep: HTTP ${String(resp.status)} ${resp.statusText}`, + { details: { url, status: resp.status, statusText: resp.statusText } }, + ); } const write = createWriteStream(archivePath); await pipeline(Readable.fromWeb(resp.body as never), write); @@ -233,9 +244,11 @@ async function downloadAndInstallRg(shareDir: string): Promise { }); const extracted = join(extractDir, `ripgrep-${RG_VERSION}-${target}`, rgBinaryName()); if (!existsSync(extracted)) { - throw new Error( + throw new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, `Ripgrep archive did not contain expected binary at ${extracted}. ` + 'CDN content may have changed.', + { details: { path: extracted } }, ); } const installDir = await mkdtemp(join(binDir, '.rg-install-')); @@ -263,9 +276,11 @@ export async function verifyArchiveChecksum( .update(await readFile(archivePath)) .digest('hex'); if (actualSha256 !== expectedSha256) { - throw new Error( + throw new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, `Ripgrep archive checksum mismatch for ${archiveName}: expected ${expectedSha256}, ` + `got ${actualSha256}. CDN content may have changed.`, + { details: { archiveName, expectedSha256, actualSha256 } }, ); } } @@ -276,7 +291,13 @@ export async function extractRgFromZip(archivePath: string, destination: string) await new Promise((resolve, reject) => { yauzlFromBuffer(buf, { lazyEntries: true }, (openErr, zipfile) => { if (openErr !== null || zipfile === undefined) { - reject(new Error(`Failed to open ripgrep archive: ${openErr?.message ?? 'unknown error'}`)); + reject( + new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, + `Failed to open ripgrep archive: ${openErr?.message ?? 'unknown error'}`, + { cause: openErr ?? undefined }, + ), + ); return; } let found = false; @@ -289,7 +310,11 @@ export async function extractRgFromZip(archivePath: string, destination: string) zipfile.openReadStream(entry, (streamErr, stream) => { if (streamErr !== null) { reject( - new Error(`Failed to read ${entry.fileName} from archive: ${streamErr.message}`), + new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, + `Failed to read ${entry.fileName} from archive: ${streamErr.message}`, + { cause: streamErr }, + ), ); zipfile.close(); return; @@ -302,7 +327,13 @@ export async function extractRgFromZip(archivePath: string, destination: string) resolve(); } catch (error) { zipfile.close(); - reject(error instanceof Error ? error : new Error(String(error))); + reject( + new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, + error instanceof Error ? error.message : String(error), + { cause: error }, + ), + ); } })(); }); @@ -311,9 +342,11 @@ export async function extractRgFromZip(archivePath: string, destination: string) zipfile.on('end', () => { if (!found) { reject( - new Error( + new Error2( + ErrorCodes.OS_FS_UNAVAILABLE, `Ripgrep archive did not contain expected binary '${binName}'. ` + 'CDN content may have changed.', + { details: { binary: binName } }, ), ); } diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts index 3775ec3b17a..6b2b43d1ff3 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts @@ -9,6 +9,7 @@ import type { Readable } from 'node:stream'; +import { BugIndicatingError } from '#/errors'; import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; export const DEFAULT_TIMEOUT_MS = 20_000; @@ -46,7 +47,7 @@ export async function runRgOnce( const [command, ...args] = rgArgs; if (command === undefined) { - throw new Error('runRgOnce: rgArgs must not be empty'); + throw new BugIndicatingError('runRgOnce: rgArgs must not be empty'); } const proc: IHostProcess = await processService.spawn(command, args, { cwd: options?.cwd }); diff --git a/packages/agent-core-v2/src/os/interface/hostProcess.ts b/packages/agent-core-v2/src/os/interface/hostProcess.ts index 390290ed5c8..20242ab82fd 100644 --- a/packages/agent-core-v2/src/os/interface/hostProcess.ts +++ b/packages/agent-core-v2/src/os/interface/hostProcess.ts @@ -54,6 +54,7 @@ export const OsProcessErrors = { codes: { OS_PROCESS_SPAWN_FAILED: 'os.process.spawn_failed', OS_PROCESS_KILL_FAILED: 'os.process.kill_failed', + SHELL_GIT_BASH_NOT_FOUND: 'shell.git_bash_not_found', }, info: { 'os.process.spawn_failed': { @@ -67,6 +68,12 @@ export const OsProcessErrors = { retryable: false, public: true, }, + 'shell.git_bash_not_found': { + title: 'Git Bash not found', + retryable: false, + public: true, + action: 'Install Git for Windows so shell commands can run under Git Bash.', + }, }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/persistence/interface/storage.ts b/packages/agent-core-v2/src/persistence/interface/storage.ts index 0e050f5e1b8..1d55183cb12 100644 --- a/packages/agent-core-v2/src/persistence/interface/storage.ts +++ b/packages/agent-core-v2/src/persistence/interface/storage.ts @@ -41,6 +41,8 @@ export const StorageErrors = { STORAGE_CORRUPTED: 'storage.corrupted', STORAGE_IO_FAILED: 'storage.io_failed', STORAGE_LOCKED: 'storage.locked', + STORAGE_PERMISSION_DENIED: 'storage.permission_denied', + STORAGE_DISK_FULL: 'storage.disk_full', }, retryable: ['storage.io_failed', 'storage.locked'], info: { @@ -72,6 +74,18 @@ export const StorageErrors = { public: true, action: 'Another process holds the store; close it or retry later.', }, + 'storage.permission_denied': { + title: 'Storage permission denied', + retryable: false, + public: true, + action: 'Check the permissions of the storage directory.', + }, + 'storage.disk_full': { + title: 'Storage disk full', + retryable: false, + public: true, + action: 'Free up disk space and retry.', + }, }, } as const satisfies ErrorDomain; @@ -96,16 +110,41 @@ function readErrno(error: unknown): string | undefined { return typeof code === 'string' ? code : undefined; } +type StorageIoErrorCode = + | typeof StorageErrors.codes.STORAGE_NOT_FOUND + | typeof StorageErrors.codes.STORAGE_IO_FAILED + | typeof StorageErrors.codes.STORAGE_PERMISSION_DENIED + | typeof StorageErrors.codes.STORAGE_DISK_FULL; + +const REASONS: Record = { + 'storage.not_found': 'path does not exist', + 'storage.io_failed': 'unrecognized I/O error', + 'storage.permission_denied': 'permission denied', + 'storage.disk_full': 'no space left on device', +}; + +function mapErrno(errno: string | undefined): StorageIoErrorCode { + switch (errno) { + case 'ENOENT': + return StorageErrors.codes.STORAGE_NOT_FOUND; + case 'EACCES': + case 'EPERM': + return StorageErrors.codes.STORAGE_PERMISSION_DENIED; + case 'ENOSPC': + return StorageErrors.codes.STORAGE_DISK_FULL; + default: + return StorageErrors.codes.STORAGE_IO_FAILED; + } +} + export function toStorageIoError(error: unknown, ctx: { path: string; op: string }): StorageError { if (error instanceof StorageError) return error; - return new StorageError( - StorageErrors.codes.STORAGE_IO_FAILED, - `storage ${ctx.op} failed`, - { - details: { path: ctx.path, op: ctx.op, errno: readErrno(error) }, - cause: error, - }, - ); + const errno = readErrno(error); + const code = mapErrno(errno); + return new StorageError(code, `storage ${ctx.op} failed: ${REASONS[code]}`, { + details: { path: ctx.path, op: ctx.op, errno }, + cause: error, + }); } export interface StorageWriteOptions { diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 6b180032bf5..b83c91b9c4d 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -22,6 +22,7 @@ import { IInstantiationService } from '#/_base/di/instantiation'; import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { Emitter } from '#/_base/event'; +import { Error2, ErrorCodes } from '#/errors'; import { join } from 'pathe'; import { createScopedChildHandle, @@ -202,9 +203,15 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle async fork(sourceAgentId: string, opts?: ForkAgentOptions): Promise { const source = this.handles.get(sourceAgentId); - if (source === undefined) throw new Error(`Source agent "${sourceAgentId}" does not exist`); + if (source === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Source agent "${sourceAgentId}" does not exist`, { + details: { agentId: sourceAgentId }, + }); + } if (opts?.agentId !== undefined && this.handles.has(opts.agentId)) { - throw new Error(`Agent "${opts.agentId}" already exists`); + throw new Error2(ErrorCodes.AGENT_ALREADY_EXISTS, `Agent "${opts.agentId}" already exists`, { + details: { agentId: opts.agentId }, + }); } const child = await this.create({ agentId: opts?.agentId, forkedFrom: source.id }); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/errors.ts b/packages/agent-core-v2/src/session/agentLifecycle/errors.ts index cfe09345a99..1432ad72bbc 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/errors.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/errors.ts @@ -7,6 +7,12 @@ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; export const AgentLifecycleErrors = { codes: { AGENT_NOT_FOUND: 'agent.not_found', + AGENT_ALREADY_EXISTS: 'agent.already_exists', + AGENT_ALREADY_RUNNING: 'agent.already_running', + AGENT_NOT_A_SUBAGENT: 'agent.not_a_subagent', + AGENT_NOT_OWNED: 'agent.not_owned', + AGENT_TYPE_NOT_ALLOWED: 'agent.type_not_allowed', + AGENT_MAX_TOKENS_EXCEEDED: 'agent.max_tokens_exceeded', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index a51a66d4b8d..94ee5566e70 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -47,6 +47,7 @@ import { IWireService } from '#/wire/wire'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; +import { BugIndicatingError } from '#/errors'; import { ICronCreateTool } from '#/agent/tools/cron/cron-create/cron-create'; import { ICronListTool } from '#/agent/tools/cron/cron-list/cron-list'; @@ -662,7 +663,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe if (!CRON_ID_REGEX.test(candidate)) continue; if (!this.tasks.has(candidate)) return candidate; } - throw new Error( + throw new BugIndicatingError( `SessionCronService: failed to generate a unique ULID after ${MAX_ID_ATTEMPTS} attempts`, ); } diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts index a642ef06a24..b38a25f229c 100644 --- a/packages/agent-core-v2/src/session/errors.ts +++ b/packages/agent-core-v2/src/session/errors.ts @@ -13,6 +13,7 @@ export const SessionErrors = { SESSION_FORK_ACTIVE_TURN: 'session.fork_active_turn', SESSION_UNDO_UNAVAILABLE: 'session.undo_unavailable', SESSION_INIT_FAILED: 'session.init_failed', + SESSION_PLAN_MODE_INVALID: 'session.plan_mode_invalid', }, retryable: ['session.fork_active_turn'], } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts index 81c39c1b5b3..2f4ecb07d56 100644 --- a/packages/agent-core-v2/src/session/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts @@ -16,6 +16,7 @@ */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { BugIndicatingError } from '#/errors'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -32,7 +33,7 @@ export class SessionProcessRunner implements ISessionProcessRunner { async exec(args: readonly string[], options?: ProcessExecOptions): Promise { const command = args[0]; if (command === undefined) { - throw new Error( + throw new BugIndicatingError( 'SessionProcessRunner.exec(): at least one argument (the command to run) is required.', ); } diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts index 9b2085d6ff0..b60579f16b6 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -21,6 +21,7 @@ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { BugIndicatingError } from '#/errors'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { @@ -82,7 +83,7 @@ export class SessionAgentProfileCatalogService getDefault(): AgentProfile { const profile = this.get(DEFAULT_AGENT_PROFILE_NAME); if (profile === undefined) { - throw new Error( + throw new BugIndicatingError( `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, ); } diff --git a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts index 044abefd7ee..0c8c839ca3f 100644 --- a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts @@ -20,7 +20,7 @@ import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; -import { ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors'; +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 { IAgentUsageService } from '#/agent/usage/usage'; @@ -58,7 +58,7 @@ export async function runAgentTurn( origin: AGENT_RUN_PROMPT_ORIGIN, } })).launched : await promptService.retry(); - if (turn === undefined) throw new Error('Agent turn could not be started'); + if (turn === undefined) throw new Error2(ErrorCodes.INTERNAL, 'Agent turn could not be started'); if (options.onReady !== undefined) { void turn.ready.then(() => options.onReady?.()).catch(() => {}); @@ -162,7 +162,7 @@ function classifyTurnResult(result: TurnResult): void { switch (result.type) { case 'completed': if (result.truncated) { - throw new Error(SUBAGENT_MAX_TOKENS_ERROR); + throw new Error2(ErrorCodes.AGENT_MAX_TOKENS_EXCEEDED, SUBAGENT_MAX_TOKENS_ERROR); } return; case 'failed': { diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index 27a66ceb9e5..a77a1599b47 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -10,6 +10,7 @@ */ import { Disposable } from '#/_base/di/lifecycle'; +import { Error2, ErrorCodes } from '#/errors'; import { type IAgentScopeHandle, LifecycleScope, @@ -54,7 +55,11 @@ export class SessionSubagentService extends Disposable implements ISessionSubage run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): Promise { const handle = this.agentLifecycle.get(agentId); - if (handle === undefined) throw new Error(`Agent "${agentId}" does not exist`); + if (handle === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent "${agentId}" does not exist`, { + details: { agentId }, + }); + } return runAgentTurn(handle, request, { summaryPolicy: opts.summaryPolicy ?? this.summaryPolicyFor(handle), signal: opts.signal, diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts index 4357d4f66b7..1518c1f8d9e 100644 --- a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts +++ b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts @@ -12,6 +12,7 @@ import { type TokenUsage } from '#/kosong/contract/usage'; import * as retry from 'retry'; import { isUserCancellation } from '#/_base/utils/abort'; +import { BugIndicatingError, Error2, ErrorCodes } from '#/errors'; import type { SessionSwarmRunResult, SessionSwarmTask } from './sessionSwarm'; @@ -155,7 +156,7 @@ export class AgentRunBatch { run(): Promise>> { if (this.started) { - throw new Error('AgentRunBatch.run() can only be called once.'); + throw new BugIndicatingError('AgentRunBatch.run() can only be called once.'); } this.started = true; @@ -643,8 +644,10 @@ export function resolveSwarmMaxConcurrency( if (raw === undefined || raw.trim() === '') return undefined; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) { - throw new Error( + throw new Error2( + ErrorCodes.VALIDATION_FAILED, `${AGENT_SWARM_MAX_CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`, + { details: { value: raw } }, ); } return value; diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 275baf6c36a..3f41f07a4f2 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -22,6 +22,7 @@ import type { TokenUsage } from '#/kosong/contract/usage'; import { IModelCatalog } from '#/kosong/model/catalog'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2, ErrorCodes } from '#/errors'; import { linkAbortSignal } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -145,11 +146,15 @@ export class SessionSwarmService implements ISessionSwarmService { await this.catalog.ready; const profile = this.catalog.get(options.profileName); if (profile === undefined) { - throw new Error(`Unknown agent type: "${options.profileName}"`); + throw new Error2(ErrorCodes.PROFILE_UNKNOWN, `Unknown agent type: "${options.profileName}"`, { + details: { profileName: options.profileName }, + }); } const callerData = caller.accessor.get(IAgentProfileService).data(); if (callerData.modelAlias === undefined) { - throw new Error('Caller agent has no model bound'); + throw new Error2(ErrorCodes.MODEL_NOT_CONFIGURED, 'Caller agent has no model bound', { + details: { agentId: callerAgentId }, + }); } const binding = options.binding ?? { model: callerData.modelAlias, @@ -249,23 +254,37 @@ export class SessionSwarmService implements ISessionSwarmService { private requireHandle(agentId: string, label: string): IAgentScopeHandle { const handle = this.lifecycle.get(agentId); - if (handle === undefined) throw new Error(`${label} "${agentId}" does not exist`); + if (handle === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `${label} "${agentId}" does not exist`, { + details: { agentId }, + }); + } return handle; } private requireIdleSubagent(agentId: string, child: IAgentScopeHandle): void { if (child.accessor.get(IAgentLoopService).status().state === 'running') { - throw new Error(`Agent instance "${agentId}" is already running and cannot run concurrently`); + throw new Error2( + ErrorCodes.AGENT_ALREADY_RUNNING, + `Agent instance "${agentId}" is already running and cannot run concurrently`, + { details: { agentId } }, + ); } } private async requireOwnedSubagent(callerAgentId: string, agentId: string): Promise { const meta = await this.agentMeta(agentId); if (!isSubagentMeta(meta)) { - throw new Error(`Agent instance "${agentId}" is not a subagent`); + throw new Error2(ErrorCodes.AGENT_NOT_A_SUBAGENT, `Agent instance "${agentId}" is not a subagent`, { + details: { agentId }, + }); } if (subagentParentAgentId(meta) !== callerAgentId) { - throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`); + throw new Error2( + ErrorCodes.AGENT_NOT_OWNED, + `Agent instance "${agentId}" does not belong to this parent agent`, + { details: { agentId, callerAgentId } }, + ); } } diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts index b4b4fd7a432..4af45b48fcb 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -15,6 +15,7 @@ import { isAbsolute, relative, resolve } from 'node:path'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; +import { ErrorCodes, Error2 } from '#/errors'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; @@ -85,7 +86,9 @@ export class SessionWorkspaceContextService extends Disposable implements ISessi assertAllowed(absPath: string, op: PathAccessOperation): string { const target = this.resolve(absPath); if (!this.isWithin(target)) { - throw new Error(`Path outside workspace (${op}): ${target}`); + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `Path outside workspace (${op}): ${target}`, { + details: { op, path: target }, + }); } return target; } diff --git a/packages/agent-core-v2/src/tool/result-builder.ts b/packages/agent-core-v2/src/tool/result-builder.ts index 4ac834f7274..9debc664788 100644 --- a/packages/agent-core-v2/src/tool/result-builder.ts +++ b/packages/agent-core-v2/src/tool/result-builder.ts @@ -6,6 +6,8 @@ * service. */ +import { BugIndicatingError } from '#/errors'; + import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from './toolContract'; const DEFAULT_MAX_CHARS = 50_000; @@ -41,7 +43,7 @@ export class ToolResultBuilder { options.maxLineLength === undefined ? DEFAULT_MAX_LINE_LENGTH : options.maxLineLength; if (this.maxLineLength !== null && this.maxLineLength <= TRUNCATION_MARKER.length) { - throw new Error('maxLineLength must be greater than the truncation marker length.'); + throw new BugIndicatingError('maxLineLength must be greater than the truncation marker length.'); } } diff --git a/packages/agent-core-v2/src/wire/errors.ts b/packages/agent-core-v2/src/wire/errors.ts index 8e4c34234fb..b40a0c73719 100644 --- a/packages/agent-core-v2/src/wire/errors.ts +++ b/packages/agent-core-v2/src/wire/errors.ts @@ -16,6 +16,7 @@ export const WireErrors = { WIRE_DUPLICATE_OP: 'wire.duplicate_op', WIRE_CYCLE: 'wire.cycle', WIRE_UNKNOWN_RECORD: 'wire.unknown_record', + WIRE_MIGRATION_MISSING: 'wire.migration_missing', RECORDS_WRITE_FAILED: 'records.write_failed', }, info: { @@ -37,6 +38,12 @@ export const WireErrors = { public: true, action: 'The record was written by a newer version; upgrade or drop it.', }, + 'wire.migration_missing': { + title: 'Wire migration missing', + retryable: false, + public: true, + action: 'The wire file predates the supported migration chain; start a new session.', + }, 'records.write_failed': { title: 'Wire journal write failed', retryable: false, diff --git a/packages/agent-core-v2/src/wire/migration/migration.ts b/packages/agent-core-v2/src/wire/migration/migration.ts index f572b886c82..b9397c3eaec 100644 --- a/packages/agent-core-v2/src/wire/migration/migration.ts +++ b/packages/agent-core-v2/src/wire/migration/migration.ts @@ -1,5 +1,7 @@ import type { WireRecord } from '#/wire/record'; +import { WireError, WireErrors } from '../errors'; + import { migrateV1_0ToV1_1 } from './v1.1'; import { migrateV1_1ToV1_2 } from './v1.2'; import { migrateV1_2ToV1_3 } from './v1.3'; @@ -46,7 +48,11 @@ export function resolveWireMigrations(readVersion: string): readonly WireMigrati while (compareWireVersions(version, WIRE_PROTOCOL_VERSION) < 0) { const migration = findMigration(version); if (migration === undefined) { - throw new Error(`Missing wire migration for version ${version}`); + throw new WireError( + WireErrors.codes.WIRE_MIGRATION_MISSING, + `Missing wire migration for version ${version}`, + { details: { version } }, + ); } migrations.push(migration); version = migration.targetVersion; diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts index 90b1c17b8b8..6d5eaba3a0d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentFile.ts @@ -11,16 +11,20 @@ * (Claude Code). */ +import { CoreErrors } from '#/_base/errors/codes'; +import { Error2 } from '#/_base/errors/errors'; import { FrontmatterError, parseFrontmatter } from '#/_base/text/frontmatter'; import type { AgentFileDefinition, AgentFileSource } from './types'; -export class AgentFileParseError extends Error { +export class AgentFileParseError extends Error2 { readonly reason?: unknown; constructor(message: string, cause?: unknown) { - super(message); - this.name = 'AgentFileParseError'; + super(CoreErrors.codes.VALIDATION_FAILED, message, { + cause, + name: 'AgentFileParseError', + }); if (cause !== undefined) this.reason = cause; } } diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/internal/rgLocator.ts b/packages/agent-core-v2/src/workspace/workspaceFs/internal/rgLocator.ts index 9492e34204c..057742c7aa1 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/internal/rgLocator.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/internal/rgLocator.ts @@ -19,6 +19,8 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; +import { ErrorCodes, Error2 } from '#/errors'; + export type RgResolutionSource = 'system-path' | 'share-bin-cached'; export interface RgResolution { @@ -75,7 +77,7 @@ export async function ensureRgPath( } } - throw new Error('ripgrep (rg) is not available on PATH'); + throw new Error2(ErrorCodes.OS_FS_UNAVAILABLE, 'ripgrep (rg) is not available on PATH'); } export function rgUnavailableMessage(cause: unknown): string { diff --git a/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts b/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts index 69b9abf8946..753ff0995b9 100644 --- a/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts @@ -14,6 +14,7 @@ */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { BugIndicatingError } from '#/errors'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { type IProcess, ISessionProcessRunner, type ProcessExecOptions } from '#/session/process/processRunner'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; @@ -29,7 +30,7 @@ export class WorkspaceProcessRunnerService implements ISessionProcessRunner { async exec(args: readonly string[], options?: ProcessExecOptions): Promise { const command = args[0]; if (command === undefined) { - throw new Error( + throw new BugIndicatingError( 'WorkspaceProcessRunnerService.exec(): at least one argument (the command to run) is required.', ); } diff --git a/packages/agent-core-v2/test/app/protocol/errors.test.ts b/packages/agent-core-v2/test/app/protocol/errors.test.ts index fb5cb266301..9f9d3130100 100644 --- a/packages/agent-core-v2/test/app/protocol/errors.test.ts +++ b/packages/agent-core-v2/test/app/protocol/errors.test.ts @@ -24,12 +24,11 @@ describe('translateProviderError', () => { expect(translateProviderError(coded)).toBe(coded); }); - it('maps 429 to provider.rate_limit, keeping the raw error as cause and status in details', () => { + it('maps 429 to provider.rate_limit at birth, passing through with status in details', () => { const raw = new APIStatusError(429, 'Too Many Requests', 'req-1'); const error = translateProviderError(raw); - expect(error).toBeInstanceOf(Error2); + expect(error).toBe(raw); expect(error.code).toBe('provider.rate_limit'); - expect(error.cause).toBe(raw); expect(error.name).toBe('APIStatusError'); expect(error.details).toMatchObject({ statusCode: 429, requestId: 'req-1' }); }); @@ -52,11 +51,11 @@ describe('translateProviderError', () => { expect(error.code).toBe('context.overflow'); }); - it('maps provider-overload errors to provider.overloaded, keeping HTTP details', () => { + it('maps provider-overload errors to provider.overloaded at birth, keeping HTTP details', () => { const raw = new APIProviderOverloadedError(529, 'Overloaded', 'req-overload'); const error = translateProviderError(raw); + expect(error).toBe(raw); expect(error.code).toBe('provider.overloaded'); - expect(error.cause).toBe(raw); expect(error.details).toMatchObject({ statusCode: 529, requestId: 'req-overload' }); }); diff --git a/packages/agent-core-v2/test/kosong/protocol/errors.test.ts b/packages/agent-core-v2/test/kosong/protocol/errors.test.ts index b30008dc6ab..e6c4afc351b 100644 --- a/packages/agent-core-v2/test/kosong/protocol/errors.test.ts +++ b/packages/agent-core-v2/test/kosong/protocol/errors.test.ts @@ -80,7 +80,7 @@ describe('translateProviderError — classification', () => { expect(translateProviderError(original)).toBe(original); }); - it('maps status errors to their codes and preserves wire details', () => { + it('maps status errors to their codes at birth and preserves wire details', () => { const cases: ReadonlyArray<[APIStatusError, string]> = [ [new APIStatusError(429, 'too many requests'), 'provider.rate_limit'], [new APIStatusError(529, 'overloaded'), 'provider.overloaded'], @@ -92,8 +92,8 @@ describe('translateProviderError — classification', () => { ]; for (const [error, code] of cases) { const translated = translateProviderError(error); + expect(translated).toBe(error); expect(translated.code).toBe(code); - expect(translated.cause).toBe(error); expect(translated.details?.['statusCode']).toBe(error.statusCode); } }); diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 7ce003fe61c..add4c2b6179 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -307,6 +307,12 @@ export const kimiErrorCodeSchema = z.enum([ 'session.question_handler_error', 'session.init_failed', 'agent.not_found', + 'agent.already_exists', + 'agent.already_running', + 'agent.not_a_subagent', + 'agent.not_owned', + 'agent.type_not_allowed', + 'agent.max_tokens_exceeded', 'activity.agent_busy', 'activity.cancelling', 'activity.disposing', @@ -343,15 +349,19 @@ export const kimiErrorCodeSchema = z.enum([ 'skill.not_found', 'skill.type_unsupported', 'skill.name_empty', + 'skill.parse_failed', + 'skill.nested_too_deep', 'records.write_failed', 'compaction.failed', 'compaction.unable', 'task.task_id_empty', + 'task.limit_exceeded', 'usage.turn_id_conflict', 'mcp.server_not_found', 'mcp.server_disabled', 'mcp.startup_failed', 'mcp.tool_name_collision', + 'mcp.oauth_failed', 'message.not_found', 'plugin.not_found', 'plugin.load_failed', @@ -376,6 +386,13 @@ export const kimiErrorCodeSchema = z.enum([ 'fs.too_many_results', 'fs.grep_timeout', 'fs.git_unavailable', + 'wire.migration_missing', + 'storage.permission_denied', + 'storage.disk_full', + 'cron.expression_invalid', + 'web.invalid_url', + 'web.private_address', + 'web.fetch_failed', 'validation.failed', 'not_implemented', 'internal', diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 6f9178d1454..82680cf57ed 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -236,6 +236,12 @@ export type KimiErrorCode = | 'session.question_handler_error' | 'session.init_failed' | 'agent.not_found' + | 'agent.already_exists' + | 'agent.already_running' + | 'agent.not_a_subagent' + | 'agent.not_owned' + | 'agent.type_not_allowed' + | 'agent.max_tokens_exceeded' | 'turn.agent_busy' | 'goal.already_exists' | 'goal.not_found' @@ -269,15 +275,19 @@ export type KimiErrorCode = | 'skill.not_found' | 'skill.type_unsupported' | 'skill.name_empty' + | 'skill.parse_failed' + | 'skill.nested_too_deep' | 'records.write_failed' | 'compaction.failed' | 'compaction.unable' | 'task.task_id_empty' + | 'task.limit_exceeded' | 'usage.turn_id_conflict' | 'mcp.server_not_found' | 'mcp.server_disabled' | 'mcp.startup_failed' | 'mcp.tool_name_collision' + | 'mcp.oauth_failed' | 'message.not_found' | 'plugin.not_found' | 'plugin.load_failed' @@ -317,9 +327,16 @@ export type KimiErrorCode = | 'storage.corrupted' | 'storage.io_failed' | 'storage.locked' + | 'storage.permission_denied' + | 'storage.disk_full' | 'wire.duplicate_op' | 'wire.cycle' | 'wire.unknown_record' + | 'wire.migration_missing' + | 'cron.expression_invalid' + | 'web.invalid_url' + | 'web.private_address' + | 'web.fetch_failed' | 'validation.failed' | 'not_implemented' | 'internal'; @@ -1170,6 +1187,12 @@ export const kimiErrorCodeSchema = z.enum([ 'session.question_handler_error', 'session.init_failed', 'agent.not_found', + 'agent.already_exists', + 'agent.already_running', + 'agent.not_a_subagent', + 'agent.not_owned', + 'agent.type_not_allowed', + 'agent.max_tokens_exceeded', 'turn.agent_busy', 'goal.already_exists', 'goal.not_found', @@ -1203,15 +1226,19 @@ export const kimiErrorCodeSchema = z.enum([ 'skill.not_found', 'skill.type_unsupported', 'skill.name_empty', + 'skill.parse_failed', + 'skill.nested_too_deep', 'records.write_failed', 'compaction.failed', 'compaction.unable', 'task.task_id_empty', + 'task.limit_exceeded', 'usage.turn_id_conflict', 'mcp.server_not_found', 'mcp.server_disabled', 'mcp.startup_failed', 'mcp.tool_name_collision', + 'mcp.oauth_failed', 'message.not_found', 'plugin.not_found', 'plugin.load_failed', @@ -1236,6 +1263,31 @@ export const kimiErrorCodeSchema = z.enum([ 'fs.too_many_results', 'fs.grep_timeout', 'fs.git_unavailable', + 'os.fs.not_found', + 'os.fs.is_directory', + 'os.fs.not_directory', + 'os.fs.already_exists', + 'os.fs.permission_denied', + 'os.fs.not_empty', + 'os.fs.unavailable', + 'os.fs.unknown', + 'os.process.spawn_failed', + 'os.process.kill_failed', + 'storage.not_found', + 'storage.decode_failed', + 'storage.corrupted', + 'storage.io_failed', + 'storage.locked', + 'storage.permission_denied', + 'storage.disk_full', + 'wire.duplicate_op', + 'wire.cycle', + 'wire.unknown_record', + 'wire.migration_missing', + 'cron.expression_invalid', + 'web.invalid_url', + 'web.private_address', + 'web.fetch_failed', 'validation.failed', 'not_implemented', 'internal', From 72ba2a6cbcc4de98cefcd8ebc2ceec5cb9bf8e95 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 3 Aug 2026 15:40:40 +0800 Subject: [PATCH 04/33] feat(agent-core-v2): add lifecycle ledger, dynamic registry, and cascade engine (#2551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-core-v2): add lifecycle ledger, dynamic registry, and cascade engine - add `_base/lifecycle` Ledger: ordered registrations with strict reverse serial teardown, sync/async dual-track disposers, uninterruptible rollback, effect return forms, child ledgers, introspection tree, and teardown-reason propagation - delegate Disposable/DisposableStore/MutableDisposable, Scope.dispose, and InstantiationService.dispose to the ledger; retire _constructionOrder - ServiceCollection: entries carry uid/pinned/recipe, delete(), and a per-token availability event; add provide/unprovide to IInstantiationService - add a persistent dependency graph recording constructor-injection edges with affectedSet/topo queries, plus a per-container cascade engine (contagion teardown/rebuild transactions, five unit states, pending index, abort hook, history ring); TestInstantiationService.set routes via provide * fix(agent-core-v2): cascade instance replacements, tolerate abort rejections - provide/unprovide of pre-materialized instances now runs as a cascade transaction too, so live dependents are torn down and rebuilt instead of holding a stale dependency; re-affirming the live instance stays a no-op - a rejecting onWillCascade promise is logged and the cascade proceeds (best-effort), matching the synchronous-throw handling * feat(agent-core-v2): propagate cascades across scopes along instance edges Implements the revised D9 (the firewall design was dropped): instance edges are scope-tagged on both ends and point child -> parent only, the dependency graph is shared by the whole scope tree, and each change runs as one tree-wide transaction orchestrated by the submitting scope's engine — contagion computed over the global graph, teardown in global reverse topological order (deepest first), rebuild in global topological order, with each scope's engine executing its own units. Descendant scopes dying mid-transaction are skipped idempotently; the request queue, in-flight set, and settle waiters are tree-shared so cross-tree transactions are serialized by the orchestrator; shadowed tokens stay outside the contagion set. --- .../src/_base/di/cascadeEngine.ts | 836 ++++++++++++++++++ .../src/_base/di/dependencyGraph.ts | 270 ++++++ packages/agent-core-v2/src/_base/di/errors.ts | 16 + .../src/_base/di/instantiation.ts | 39 +- .../src/_base/di/instantiationService.ts | 322 ++++++- .../agent-core-v2/src/_base/di/lifecycle.ts | 57 +- packages/agent-core-v2/src/_base/di/scope.ts | 35 +- .../src/_base/di/serviceCollection.ts | 129 ++- .../src/_base/di/testInstantiationService.ts | 20 +- .../src/_base/lifecycle/disposer.ts | 56 ++ .../src/_base/lifecycle/errors.ts | 16 + .../src/_base/lifecycle/index.ts | 4 + .../src/_base/lifecycle/ledger.ts | 352 ++++++++ .../test/_base/di/cascade.test.ts | 653 ++++++++++++++ .../agent-core-v2/test/_base/di/child.test.ts | 27 +- .../test/_base/di/provide.test.ts | 239 +++++ .../test/_base/lifecycle/ledger.test.ts | 427 +++++++++ 17 files changed, 3407 insertions(+), 91 deletions(-) create mode 100644 packages/agent-core-v2/src/_base/di/cascadeEngine.ts create mode 100644 packages/agent-core-v2/src/_base/di/dependencyGraph.ts create mode 100644 packages/agent-core-v2/src/_base/lifecycle/disposer.ts create mode 100644 packages/agent-core-v2/src/_base/lifecycle/errors.ts create mode 100644 packages/agent-core-v2/src/_base/lifecycle/index.ts create mode 100644 packages/agent-core-v2/src/_base/lifecycle/ledger.ts create mode 100644 packages/agent-core-v2/test/_base/di/cascade.test.ts create mode 100644 packages/agent-core-v2/test/_base/di/provide.test.ts create mode 100644 packages/agent-core-v2/test/_base/lifecycle/ledger.test.ts diff --git a/packages/agent-core-v2/src/_base/di/cascadeEngine.ts b/packages/agent-core-v2/src/_base/di/cascadeEngine.ts new file mode 100644 index 00000000000..34d7d9d6bed --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/cascadeEngine.ts @@ -0,0 +1,836 @@ +/** + * `di` domain — cascade engine + wait scheduler (L2), one per container, with + * tree-wide orchestration (D9: cascades propagate along instance edges across + * scopes). + * + * The dependency graph, request queue, in-flight set, and settle waiters are + * shared by the whole scope tree (`CascadeTree`, owned by the root). Every + * change (provide / unprovide / update) runs as a single transaction + * orchestrated by the engine of the scope where the change was submitted: + * ① compute the contagion set from the tree-global graph; + * ② broadcast WillCascade to the orchestrator's abort hook (bounded wait, + * then forced; failures are best-effort, never a veto); + * ③ tear the contagion set down in global reverse topological order, serially + * (each scope's engine executes its own units; Active → Unloading → + * Pending, or removed for an unprovided token; a descendant scope that dies + * mid-transaction is skipped idempotently); + * ④ apply the change in its own scope (a replace never passes through the + * waiting area); + * ⑤ recheck the waiting area across scopes and rebuild satisfied units in + * global topological order; + * ⑥ append the transaction to the orchestrator's history ring. + * + * Requests serialize through the tree queue; requests queued together merge + * their contagion sets (deduped by scope+token) into one transaction. This is + * one transaction across the tree but not a distributed transaction: a single + * orchestrator, a deterministic order, local execution per scope. Like the + * Ledger, the engine has a sync fast path: with no async abort wait and no + * async disposers, a transaction completes within the tick. + */ + +import { onUnexpectedError } from '../errors/unexpectedError'; +import { isPromiseLike } from '../lifecycle/disposer'; +import type { SyncDescriptor } from './descriptors'; +import { + PairIndex, + type DependencyGraph, + type ScopedToken, +} from './dependencyGraph'; +import { CascadeConflictError } from './errors'; +import type { ServiceIdentifier } from './instantiation'; + +export type UnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; + +export type CascadeAction = 'provide' | 'unprovide' | 'update'; + +/** Eager units activate as soon as their dependencies are satisfied; on-demand units wait for their first resolution (but cascade-torn units always rebuild). */ +export type UnitActivation = 'eager' | 'ondemand'; + +export interface CascadeChange { + readonly action: CascadeAction; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly token: ServiceIdentifier; + readonly descriptor?: SyncDescriptor; + /** Pre-materialized value for a `provide` change (mutually exclusive with `descriptor`). */ + readonly instance?: unknown; + readonly pinned?: boolean; + readonly activation?: UnitActivation; + readonly reason: string; +} + +export interface CascadeHistoryEntry { + readonly seq: number; + readonly reason: string; + readonly changes: ReadonlyArray<{ token: string; action: CascadeAction }>; + readonly affected: readonly string[]; + readonly tornDown: readonly string[]; + readonly rebuilt: readonly string[]; + readonly failed: readonly string[]; + readonly abortWaited: boolean; + readonly abortTimedOut: boolean; + readonly durationMs: number; +} + +export interface CascadeEngineOptions { + /** + * Abort hook (§4.5): invoked at transaction step ② with the contagion set. + * A returned promise is awaited up to `abortWaitMs` (best-effort), then the + * cascade proceeds anyway (forced teardown). + */ + onWillCascade?: ( + affected: readonly ScopedToken[], + reason: string, + ) => void | Promise; + /** Bounded wait for in-flight work to abort (default 5000ms). */ + readonly abortWaitMs?: number; + /** Suspended-resolution timeout (default 30000ms). */ + readonly resolveTimeoutMs?: number; + /** History ring capacity (default 200). */ + readonly historyCapacity?: number; + readonly now?: () => number; +} + +/** Container operations one engine drives for its own scope's units. */ +export interface CascadeHost { + /** Registered in this container or an ancestor. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isRegistered(token: ServiceIdentifier): boolean; + /** The container owning this token in this container's chain, if any. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ownerScopeOf(token: ServiceIdentifier): object | undefined; + /** Has a live materialized instance in this container. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMaterialized(token: ServiceIdentifier): boolean; + /** Create + cache the instance (throws on construction failure). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + materialize(token: ServiceIdentifier): unknown; + /** Tear the live instance down and reset the entry to its recipe. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + retire(token: ServiceIdentifier): void | Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + applyProvide( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + token: ServiceIdentifier, + descriptor: SyncDescriptor, + pinned: boolean | undefined, + ): number; + /** Register a pre-materialized instance (a new generation). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + applyProvideInstance( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + token: ServiceIdentifier, + instance: unknown, + pinned: boolean | undefined, + ): number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + applyUnprovide(token: ServiceIdentifier): void; + /** The unit's recipe: the pending descriptor, or the retained one of a live instance. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + recipeOf(token: ServiceIdentifier): SyncDescriptor | undefined; + /** Constructor-declared (instance-edge) dependencies of a recipe. */ + dependenciesOf( + recipe: SyncDescriptor, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Array>; +} + +/** Structural handle a scoped token's `scope` provides to the engine. */ +export interface CascadeScopeHandle { + readonly cascade: CascadeEngine; + readonly cascadeDisposed: boolean; + /** Distance from the tree root (root = 0); parents always sort shallower. */ + readonly cascadeDepth: number; +} + +interface UnitRecord { + state: UnitState; + error?: unknown; + activation: UnitActivation; + /** True once the unit has had a live instance (torn-down units always rebuild). */ + everActive: boolean; +} + +interface QueuedRequest { + readonly engine: CascadeEngine; + readonly change: CascadeChange; + readonly resolve: () => void; + readonly reject: (error: unknown) => void; +} + +const DEFAULT_ABORT_WAIT_MS = 5000; +const DEFAULT_RESOLVE_TIMEOUT_MS = 30000; +const DEFAULT_HISTORY_CAPACITY = 200; + +/** + * Tree-wide cascade runtime, owned by the root container and shared by every + * engine of the scope tree: the persistent graph, the serialized request + * queue, the in-flight contagion set of the running transaction, and the + * settle waiters for suspended resolutions. + */ +export class CascadeTree { + readonly graph: DependencyGraph; + readonly queue: QueuedRequest[] = []; + /** Every live engine of the tree (engines register at construction). */ + readonly engines = new Set(); + running = false; + /** The scope orchestrating the running transaction (for label rendering). */ + orchestrator: object | undefined; + private readonly _inFlight = new PairIndex(); + private _settleWaiters: Array<() => void> = []; + private readonly _scopeSeq = new Map(); + private _nextScopeSeq = 0; + + constructor(graph: DependencyGraph) { + this.graph = graph; + } + + inFlightSet(ref: ScopedToken, on: boolean): void { + if (on) { + this._inFlight.set(ref.scope, ref.token, true); + } else { + this._inFlight.delete(ref.scope, ref.token); + } + } + + inFlightHas(ref: ScopedToken): boolean { + return this._inFlight.get(ref.scope, ref.token) !== undefined; + } + + inFlightClear(refs: Iterable): void { + for (const ref of refs) { + this._inFlight.delete(ref.scope, ref.token); + } + } + + /** Stable per-scope sequence used to render cross-scope labels (`#n:token`). */ + seqOf(scope: object): number { + let seq = this._scopeSeq.get(scope); + if (seq === undefined) { + seq = this._nextScopeSeq++; + this._scopeSeq.set(scope, seq); + } + return seq; + } + + addSettleWaiter(waiter: () => void): void { + this._settleWaiters.push(waiter); + } + + fireSettleWaiters(): void { + const waiters = this._settleWaiters.splice(0); + for (const waiter of waiters) { + waiter(); + } + } +} + +export class CascadeEngine { + private readonly _units = new Map< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ServiceIdentifier, + UnitRecord + >(); + /** missing dependency token → waiting unit tokens (§5.5 wake intersection). */ + private readonly _pendingIndex = new Map< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Set> + >(); + private readonly _history: CascadeHistoryEntry[] = []; + private _historySeq = 0; + private _disposed = false; + + constructor( + private readonly _host: CascadeHost, + private readonly _scope: CascadeScopeHandle, + private readonly _tree: CascadeTree, + private _options: CascadeEngineOptions = {}, + ) { + this._tree.engines.add(this); + } + + /** Merge new options (tests configure hooks/timeouts per scenario). */ + configure(options: CascadeEngineOptions): void { + this._options = { ...this._options, ...options }; + } + + /** State of an engine-tracked unit; undefined for tokens the engine never saw. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + unitState(token: ServiceIdentifier): UnitState | undefined { + return this._units.get(token)?.state; + } + + /** The sticky failure of a Failed unit. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + unitFailure(token: ServiceIdentifier): unknown { + const unit = this._units.get(token); + return unit?.state === 'Failed' ? unit.error : undefined; + } + + /** True while the scoped token sits inside the running transaction's contagion set. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isInFlight(token: ServiceIdentifier): boolean { + const owner = this._host.ownerScopeOf(token) ?? this._scope; + return this._tree.inFlightHas({ scope: owner, token }); + } + + history(): readonly CascadeHistoryEntry[] { + return this._history; + } + + /** Waiting-area snapshot for introspection: waiting unit → missing tokens. */ + pendingSnapshot(): ReadonlyMap { + const snapshot = new Map(); + for (const [token, unit] of this._units) { + if (unit.state !== 'Pending') continue; + snapshot.set( + token.toString(), + this._missingDeps(token).map((dep) => dep.toString()), + ); + } + return snapshot; + } + + /** + * Queue a change on the tree. Queued requests merge (deduped by + * scope+token) into the next transaction. The returned promise settles when + * the transaction that applied the change completes; with the sync fast + * path the change is already applied when `submit` returns. + */ + submit(change: CascadeChange): Promise { + if (this._disposed) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + this._tree.queue.push({ engine: this, change, resolve, reject }); + this._pump(); + }); + } + + /** Explicit reload of a unit (D5): a replace-self transaction. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + update(token: ServiceIdentifier, reason?: string): Promise { + return this.submit({ + action: 'update', + token, + reason: reason ?? `update ${String(token)}`, + }); + } + + /** Settles when no transaction is running and the tree queue is empty. */ + whenIdle(): Promise { + if (!this._tree.running && this._tree.queue.length === 0) { + return Promise.resolve(); + } + return new Promise((resolve) => { + this._tree.addSettleWaiter(() => { + void this.whenIdle().then(resolve); + }); + }); + } + + /** + * Async resolution path (§4.3): a token inside the running transaction's + * contagion set suspends until the transaction completes (then resolves); + * anything else resolves immediately. Times out with `CascadeConflictError`. + */ + resolveWhenAvailable( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + token: ServiceIdentifier, + timeoutMs?: number, + ): Promise { + if (!this.isInFlight(token)) { + try { + return Promise.resolve(this._host.materialize(token) as T); + } catch (error) { + return Promise.reject(error); + } + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new CascadeConflictError( + String(token), + 'timed out waiting for the in-flight cascade to settle', + ), + ); + }, timeoutMs ?? this._options.resolveTimeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS); + this._tree.addSettleWaiter(() => { + clearTimeout(timer); + try { + resolve(this._host.materialize(token) as T); + } catch (error) { + reject(error); + } + }); + }); + } + + /** Container-side notification: a unit was materialized outside activation. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + observedMaterialization(token: ServiceIdentifier): void { + const unit = this._units.get(token); + if (unit !== undefined && unit.state === 'Pending') { + unit.state = 'Active'; + unit.everActive = true; + unit.error = undefined; + } + } + + dispose(): void { + this._disposed = true; + this._tree.engines.delete(this); + this._units.clear(); + this._pendingIndex.clear(); + // Only this engine's queued requests are withdrawn; the tree queue keeps + // serving the other scopes. + const remaining: QueuedRequest[] = []; + for (const request of this._tree.queue) { + if (request.engine === this) { + request.resolve(); + } else { + remaining.push(request); + } + } + this._tree.queue.length = 0; + this._tree.queue.push(...remaining); + } + + // ------------------------------------------------------ orchestrator ops + + /** + * Orchestrator-driven: tear down one of THIS engine's live units + * (Active → Unloading → Pending). Idempotently skipped when this engine's + * scope died mid-transaction. `parkAsPending` is false for the unprovide + * target itself (removal is not a state). + */ + _teardownForCascade( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + token: ServiceIdentifier, + tornDown: string[], + parkAsPending: boolean, + ): void | Promise { + if (this._disposed || !this._host.isMaterialized(token)) { + return undefined; + } + this._unitFor(token).state = 'Unloading'; + const out = this._host.retire(token); + tornDown.push(this._label({ scope: this._scope, token })); + if (parkAsPending) { + // Dependents and replaced units go back to the waiting area with their + // recipe retained; they were live, so they always rebuild. + this._markPending(token, undefined, true); + } + return out; + } + + /** Orchestrator-driven: recheck THIS engine's waiting area (transaction ⑤). */ + _recheckForCascade(rebuilt: string[], failed: string[]): void { + if (this._disposed) { + return; + } + this._recheckPending(rebuilt, failed); + } + + /** Orchestrator-driven: apply THIS engine's own change (transaction ④). */ + _applyChangeForCascade(change: CascadeChange): void { + if (this._disposed) { + return; + } + switch (change.action) { + case 'provide': + if (change.descriptor !== undefined) { + this._host.applyProvide(change.token, change.descriptor, change.pinned); + this._markPending(change.token, change.activation ?? 'eager', false); + } else { + // Pre-materialized instance: a new generation that is already + // live — no activation to schedule, dependents rebuild below. + this._host.applyProvideInstance(change.token, change.instance, change.pinned); + const unit = this._unitFor(change.token); + unit.state = 'Active'; + unit.everActive = true; + unit.error = undefined; + } + break; + case 'unprovide': + this._host.applyUnprovide(change.token); + this._units.delete(change.token); + break; + case 'update': + // Reload of a live-or-failed unit: it rebuilds like a torn one. + this._markPending(change.token, undefined, true); + break; + } + } + + // ------------------------------------------------------------------ queue + + private _pump(): void { + if (this._tree.running) { + return; + } + const batch = this._tree.queue.splice(0); + if (batch.length === 0) { + return; + } + this._tree.running = true; + const finish = (error?: unknown): void => { + this._tree.running = false; + for (const request of batch) { + if (error === undefined) { + request.resolve(); + } else { + request.reject(error); + } + } + this._tree.fireSettleWaiters(); + this._pump(); + }; + // The first request's engine orchestrates the merged transaction. + const orchestrator = batch[0]!.engine; + try { + const out = orchestrator._transact(batch); + if (isPromiseLike(out)) { + Promise.resolve(out).then( + () => { finish(); }, + (error: unknown) => { finish(error); }, + ); + } else { + finish(); + } + } catch (error) { + finish(error); + } + } + + // ------------------------------------------------------------ transaction + + private _transact(batch: QueuedRequest[]): void | Promise { + const changes = mergeBatch(batch); + const started = this._options.now?.() ?? Date.now(); + const reason = changes.map(({ change }) => change.reason).join('; '); + // ① contagion set from the tree-global graph (includes the changed tokens). + const affected = this._tree.graph.affectedSet( + changes.map(({ engine, change }) => ({ scope: engine._scope, token: change.token })), + ); + for (const ref of affected) { + this._tree.inFlightSet(ref, true); + } + this._tree.orchestrator = this._scope; + const complete = (abort: { waited: boolean; timedOut: boolean }): void | Promise => { + const clear = (): void => { + this._tree.inFlightClear(affected); + this._tree.orchestrator = undefined; + }; + let out: void | Promise; + try { + out = this._applyTransaction(changes, affected, reason, started, abort); + } catch (error) { + clear(); + throw error; + } + if (isPromiseLike(out)) { + // The contagion set stays in flight until the transaction settles. + return Promise.resolve(out).then(clear, (error: unknown) => { + clear(); + throw error; + }); + } + clear(); + return undefined; + }; + // ② WillCascade broadcast → abort hook (bounded wait, best-effort). + const wait = this._waitForAbort(affected, reason); + if (isPromiseLike(wait)) { + return Promise.resolve(wait).then(complete); + } + return complete(wait); + } + + private _waitForAbort( + affected: readonly ScopedToken[], + reason: string, + ): { waited: boolean; timedOut: boolean } | Promise<{ waited: boolean; timedOut: boolean }> { + const hook = this._options.onWillCascade; + if (hook === undefined) { + return { waited: false, timedOut: false }; + } + let out: void | Promise; + try { + out = hook(affected, reason); + } catch (error) { + onUnexpectedError(error); + return { waited: false, timedOut: false }; + } + if (!isPromiseLike(out)) { + return { waited: false, timedOut: false }; + } + const waitMs = this._options.abortWaitMs ?? DEFAULT_ABORT_WAIT_MS; + return Promise.race([ + Promise.resolve(out).then( + () => ({ waited: true, timedOut: false }), + (error: unknown) => { + // Best-effort (§4.5): an async abort failure is logged, never a veto. + onUnexpectedError(error); + return { waited: true, timedOut: false }; + }, + ), + new Promise<{ waited: boolean; timedOut: boolean }>((resolve) => { + setTimeout(() => { resolve({ waited: true, timedOut: true }); }, waitMs); + }), + ]); + } + + private _applyTransaction( + changes: QueuedRequest[], + affected: readonly ScopedToken[], + reason: string, + started: number, + abort: { waited: boolean; timedOut: boolean }, + ): void | Promise { + const tornDown: string[] = []; + const rebuilt: string[] = []; + const failed: string[] = []; + + // ③ tear the contagion set down in global reverse topological order, + // serially; each scope's engine executes its own units. + const teardownOrder = this._tree.graph.reverseTopoOrder(affected); + let index = 0; + const step = (): void | Promise => { + while (index < teardownOrder.length) { + const ref = teardownOrder[index]!; + index += 1; + const owner = engineOf(ref); + if (owner === undefined || owner._disposed) { + continue; // descendant scope died mid-transaction: skip idempotently + } + // The unprovide target itself is removed in ④, not parked as Pending. + const removed = changes.some( + ({ engine, change }) => + engine === owner && change.token === ref.token && change.action === 'unprovide', + ); + const out = owner._teardownForCascade(ref.token, tornDown, !removed); + if (isPromiseLike(out)) { + return Promise.resolve(out).then(step); + } + } + return undefined; + }; + + const after = (): void => { + // ④ apply each change in its own scope. + for (const { engine, change } of changes) { + engine._applyChangeForCascade(change); + } + // ⑤ recheck the waiting area across the tree: every live engine, in + // shallow-first order (dependencies only point upward, so depth order + // is a valid global topological order across scopes), iterated to a + // fixpoint — activating one unit may satisfy the next. + const enginesInOrder = [...this._tree.engines] + .filter((engine) => !engine._disposed) + .sort((a, b) => a._scope.cascadeDepth - b._scope.cascadeDepth); + for (;;) { + let progress = false; + for (const engine of enginesInOrder) { + const before = rebuilt.length + failed.length; + engine._recheckForCascade(rebuilt, failed); + if (rebuilt.length + failed.length > before) { + progress = true; + } + } + if (!progress) { + break; + } + } + // ⑥ history ring (orchestrator-local). + this._pushHistory({ + seq: ++this._historySeq, + reason, + changes: changes.map(({ engine, change }) => ({ + token: this._label({ scope: engine._scope, token: change.token }), + action: change.action, + })), + affected: affected.map((ref) => this._label(ref)), + tornDown, + rebuilt, + failed, + abortWaited: abort.waited, + abortTimedOut: abort.timedOut, + durationMs: (this._options.now?.() ?? Date.now()) - started, + }); + }; + + const drained = step(); + if (isPromiseLike(drained)) { + return Promise.resolve(drained).then(after); + } + after(); + return undefined; + } + + // ------------------------------------------------------------------ units + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _unitFor(token: ServiceIdentifier): UnitRecord { + let unit = this._units.get(token); + if (unit === undefined) { + unit = { state: 'Pending', activation: 'eager', everActive: false }; + this._units.set(token, unit); + } + return unit; + } + + /** Back to the waiting area; `everActive` marks a cascade-torn unit (always rebuilds). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _markPending( + token: ServiceIdentifier, + activation?: UnitActivation, + everActive?: boolean, + ): void { + const unit = this._unitFor(token); + unit.state = 'Pending'; + unit.error = undefined; + if (activation !== undefined) { + unit.activation = activation; + } + if (everActive !== undefined) { + unit.everActive = everActive; + } + } + + /** + * ⑤ for one engine: rebuild the missing-token index from scratch (cheap: + * one sweep of the waiting area), then activate every satisfied unit in + * topological order, iterating to a fixpoint. Satisfaction consults the + * dependency's OWNING engine across scopes (D9). + */ + private _recheckPending(rebuilt: string[], failed: string[]): void { + for (;;) { + this._pendingIndex.clear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const satisfied: ServiceIdentifier[] = []; + for (const [token, unit] of this._units) { + if (unit.state !== 'Pending') continue; + const missing = this._missingDeps(token); + if (missing.length === 0) { + // On-demand units that were never live wait for their first + // resolution instead of auto-activating. + if (unit.everActive || unit.activation === 'eager') { + satisfied.push(token); + } + } else { + for (const dep of missing) { + let waiters = this._pendingIndex.get(dep); + if (waiters === undefined) { + waiters = new Set(); + this._pendingIndex.set(dep, waiters); + } + waiters.add(token); + } + } + } + if (satisfied.length === 0) { + return; + } + const ordered = this._tree.graph.topoOrder( + satisfied.map((token) => ({ scope: this._scope, token })), + ); + for (const ref of ordered) { + this._activate(ref.token, rebuilt, failed); + } + // Materialization pulls descriptor dependencies transitively; sweep the + // units that became materialized as a side effect. + for (const [token, unit] of this._units) { + if ( + (unit.state === 'Pending' || unit.state === 'Activating') && + this._host.isMaterialized(token) + ) { + unit.state = 'Active'; + unit.everActive = true; + } + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _activate(token: ServiceIdentifier, rebuilt: string[], failed: string[]): void { + const unit = this._unitFor(token); + unit.state = 'Activating'; + unit.error = undefined; + try { + this._host.materialize(token); + unit.state = 'Active'; + unit.everActive = true; + rebuilt.push(this._label({ scope: this._scope, token })); + } catch (error) { + // D5: Failed is sticky — no automatic retry; explicit update() reloads. + unit.state = 'Failed'; + unit.error = error; + failed.push(this._label({ scope: this._scope, token })); + } + } + + /** Missing dependencies of a Pending unit (empty = ready to activate). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _missingDeps(token: ServiceIdentifier): Array> { + const recipe = this._host.recipeOf(token); + if (recipe === undefined) { + // No recipe to rebuild from (e.g. a foreign-seeded instance that was + // torn down): the unit can never become satisfied on its own. + return [token]; + } + return this._host + .dependenciesOf(recipe) + .filter((dep) => !this._isAvailable(dep)); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _isAvailable(dep: ServiceIdentifier): boolean { + if (!this._host.isRegistered(dep)) { + return false; + } + // The dependency's state is owned by the engine of the scope that + // registered it (an ancestor's engine for a cross-scope injection). + const owner = this._host.ownerScopeOf(dep); + const engine = owner === undefined ? undefined : engineOf({ scope: owner, token: dep }); + const state = engine?.unitState(dep); + return state === undefined || state === 'Active'; + } + + /** Render a scoped token for history: plain for the orchestrator's scope, `#n:token` for others. */ + private _label(ref: ScopedToken): string { + if (ref.scope === this._tree.orchestrator) { + return ref.token.toString(); + } + return `#${this._tree.seqOf(ref.scope)}:${ref.token.toString()}`; + } + + /** Merge-queue dedupe key for this engine's scope. */ + _mergeKey(): string { + return String(this._tree.seqOf(this._scope)); + } + + private _pushHistory(entry: CascadeHistoryEntry): void { + this._history.push(entry); + const capacity = this._options.historyCapacity ?? DEFAULT_HISTORY_CAPACITY; + if (this._history.length > capacity) { + this._history.splice(0, this._history.length - capacity); + } + } +} + +/** Resolve a scoped token to its scope's engine (structural handle). */ +function engineOf(ref: ScopedToken): CascadeEngine | undefined { + return (ref.scope as Partial).cascade; +} + +/** Queued requests merge: one change per scope+token (latest wins), order preserved. */ +function mergeBatch(batch: QueuedRequest[]): QueuedRequest[] { + const byKey = new Map(); + for (const request of batch) { + const key = `${request.engine._mergeKey()}:${request.change.token.toString()}`; + byKey.set(key, request); + } + return [...byKey.values()]; +} diff --git a/packages/agent-core-v2/src/_base/di/dependencyGraph.ts b/packages/agent-core-v2/src/_base/di/dependencyGraph.ts new file mode 100644 index 00000000000..b52c1439138 --- /dev/null +++ b/packages/agent-core-v2/src/_base/di/dependencyGraph.ts @@ -0,0 +1,270 @@ +/** + * `di` domain — persistent dependency graph (L2 substrate), tree-global. + * + * One graph is shared by every container of a scope tree. Edges are recorded + * when a service's constructor dependencies are resolved and removed when the + * consumer is torn down, so the graph always mirrors the live containers. + * Both ends of an edge are scope-tagged: a consumer in a child scope may bind + * a token owned by an ancestor scope (child → parent only — a parent can never + * resolve a child's token, so cross-tree cycles are impossible by + * construction). Instance edges bind a consumer to its dependency's + * generation (the dependency changes → the consumer is torn down and rebuilt, + * across scopes); collection edges (Phase 3) are recorded for introspection + * but never join a cascade contagion set. + */ + +import type { ServiceIdentifier } from './instantiation'; + +/** A token as seen from the tree: the owning container plus the identifier. */ +export interface ScopedToken { + /** The container whose collection owns the registration. */ + readonly scope: object; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly token: ServiceIdentifier; +} + +export type DependencyEdgeKind = 'instance' | 'collection'; + +export interface DependencyEdge { + readonly consumer: ScopedToken; + readonly dependency: ScopedToken; + readonly kind: DependencyEdgeKind; +} + +/** Nested scope → token maps, so scoped tokens stay structural (no interning). */ +export class PairIndex { + private readonly _map = new Map, + V + >>(); + + get(scope: object, token: ServiceIdentifier): V | undefined { + return this._map.get(scope)?.get(token); + } + + set(scope: object, token: ServiceIdentifier, value: V): void { + let inner = this._map.get(scope); + if (inner === undefined) { + inner = new Map(); + this._map.set(scope, inner); + } + inner.set(token, value); + } + + delete(scope: object, token: ServiceIdentifier): void { + const inner = this._map.get(scope); + if (inner === undefined) return; + inner.delete(token); + if (inner.size === 0) { + this._map.delete(scope); + } + } + + entries(): Array<[ScopedToken, V]> { + const out: Array<[ScopedToken, V]> = []; + for (const [scope, inner] of this._map) { + for (const [token, value] of inner) { + out.push([{ scope, token }, value]); + } + } + return out; + } + + clear(): void { + this._map.clear(); + } +} + +export class DependencyGraph { + /** live instance → its scoped token */ + private readonly _refByInstance = new Map(); + /** scoped token → live instance */ + private readonly _instanceByRef = new PairIndex(); + /** consumer instance → (dependency scoped token → edge kind) */ + private readonly _out = new Map>(); + /** dependency scoped token → (consumer instance → edge kind) */ + private readonly _in = new PairIndex>(); + + /** Register a materialized service instance so its edges can be tracked. */ + addInstance( + instance: object, + scope: object, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + token: ServiceIdentifier, + ): void { + const ref: ScopedToken = { scope, token }; + this._refByInstance.set(instance, ref); + this._instanceByRef.set(scope, token, instance); + } + + /** Drop a consumer: its outbound edges and token mapping (inbound edges stay). */ + removeInstance(instance: object): void { + const ref = this._refByInstance.get(instance); + if (ref !== undefined) { + this._refByInstance.delete(instance); + if (this._instanceByRef.get(ref.scope, ref.token) === instance) { + this._instanceByRef.delete(ref.scope, ref.token); + } + } + const out = this._out.get(instance); + if (out !== undefined) { + for (const [dependency] of out.entries()) { + this._in.get(dependency.scope, dependency.token)?.delete(instance); + } + this._out.delete(instance); + } + } + + addEdge( + consumerInstance: object, + dependency: ScopedToken, + kind: DependencyEdgeKind = 'instance', + ): void { + let out = this._out.get(consumerInstance); + if (out === undefined) { + out = new PairIndex(); + this._out.set(consumerInstance, out); + } + out.set(dependency.scope, dependency.token, kind); + + let inbound = this._in.get(dependency.scope, dependency.token); + if (inbound === undefined) { + inbound = new Map(); + this._in.set(dependency.scope, dependency.token, inbound); + } + inbound.set(consumerInstance, kind); + } + + /** + * The contagion set: the changed scoped tokens plus every scoped token whose + * live instance transitively depends on them through instance edges — + * computed across the whole tree (dependents always live in the changed + * scope's subtree, since edges point child → parent). + */ + affectedSet(changed: Iterable): ScopedToken[] { + const seen = new PairIndex(); + const queue: ScopedToken[] = []; + const push = (ref: ScopedToken): void => { + if (seen.get(ref.scope, ref.token) !== undefined) return; + seen.set(ref.scope, ref.token, true); + queue.push(ref); + }; + for (const ref of changed) { + push(ref); + } + const affected: ScopedToken[] = []; + while (queue.length > 0) { + const ref = queue.pop()!; + affected.push(ref); + const inbound = this._in.get(ref.scope, ref.token); + if (inbound === undefined) continue; + for (const [consumerInstance, kind] of inbound) { + if (kind !== 'instance') continue; + const consumer = this._refByInstance.get(consumerInstance); + if (consumer === undefined) continue; + push(consumer); + } + } + return affected; + } + + /** Dependencies-first order over the given scoped-token subset (instance edges). */ + topoOrder(tokens: Iterable): ScopedToken[] { + const subset = new PairIndex(); + for (const ref of tokens) { + subset.set(ref.scope, ref.token, ref); + } + const ordered: ScopedToken[] = []; + const done = new PairIndex(); + const visit = (ref: ScopedToken): void => { + if (done.get(ref.scope, ref.token) !== undefined) return; + done.set(ref.scope, ref.token, true); + for (const dependency of this._dependenciesOf(ref, subset)) { + visit(dependency); + } + ordered.push(ref); + }; + for (const [, ref] of subset.entries()) { + visit(ref); + } + return ordered; + } + + /** Dependents-first order (teardown order) over the given scoped-token subset. */ + reverseTopoOrder(tokens: Iterable): ScopedToken[] { + return this.topoOrder(tokens).toReversed(); + } + + /** Instance-edge cycle check over the live graph; returns the cycle path or null. */ + findCycle(label: (ref: ScopedToken) => string): string[] | null { + const state = new PairIndex<'visiting' | 'done'>(); + const path: ScopedToken[] = []; + const visit = (ref: ScopedToken): string[] | null => { + state.set(ref.scope, ref.token, 'visiting'); + path.push(ref); + for (const dependency of this._dependenciesOf(ref, undefined)) { + const mark = state.get(dependency.scope, dependency.token); + if (mark === 'done') continue; + if (mark === 'visiting') { + const start = path.findIndex( + (entry) => + entry.scope === dependency.scope && entry.token === dependency.token, + ); + return [...path.slice(start), dependency].map(label); + } + const cycle = visit(dependency); + if (cycle !== null) return cycle; + } + path.pop(); + state.set(ref.scope, ref.token, 'done'); + return null; + }; + for (const [ref] of this._instanceByRef.entries()) { + if (state.get(ref.scope, ref.token) !== undefined) continue; + const cycle = visit(ref); + if (cycle !== null) return cycle; + } + return null; + } + + /** Introspection: every live edge (both kinds), scoped on both ends. */ + edges(): DependencyEdge[] { + const edges: DependencyEdge[] = []; + for (const [consumerInstance, out] of this._out) { + const consumer = this._refByInstance.get(consumerInstance); + if (consumer === undefined) continue; + for (const [dependency, kind] of out.entries()) { + edges.push({ consumer, dependency, kind }); + } + } + return edges; + } + + clear(): void { + this._refByInstance.clear(); + this._instanceByRef.clear(); + this._out.clear(); + this._in.clear(); + } + + /** In-subset instance-edge dependencies of a scoped token's live instance. */ + private _dependenciesOf( + ref: ScopedToken, + subset: PairIndex | undefined, + ): ScopedToken[] { + const instance = this._instanceByRef.get(ref.scope, ref.token); + if (instance === undefined) return []; + const out = this._out.get(instance); + if (out === undefined) return []; + const result: ScopedToken[] = []; + for (const [dependency, kind] of out.entries()) { + if (kind !== 'instance') continue; + if (subset !== undefined && subset.get(dependency.scope, dependency.token) === undefined) { + continue; + } + result.push(dependency); + } + return result; + } +} diff --git a/packages/agent-core-v2/src/_base/di/errors.ts b/packages/agent-core-v2/src/_base/di/errors.ts index 5d49dafc055..d1de9d4153d 100644 --- a/packages/agent-core-v2/src/_base/di/errors.ts +++ b/packages/agent-core-v2/src/_base/di/errors.ts @@ -24,3 +24,19 @@ export class CyclicDependencyError extends Error { this.name = 'CyclicDependencyError'; } } + +/** + * Raised when a resolution hits a token inside an in-flight cascade + * transaction's contagion set. The async resolution path suspends instead + * (see `CascadeEngine.resolveWhenAvailable`); the sync path cannot suspend, + * so it fails fast with this error. + */ +export class CascadeConflictError extends Error { + constructor( + readonly token: string, + readonly detail: string, + ) { + super(`Cascade conflict resolving '${token}': ${detail}`); + this.name = 'CascadeConflictError'; + } +} diff --git a/packages/agent-core-v2/src/_base/di/instantiation.ts b/packages/agent-core-v2/src/_base/di/instantiation.ts index 673c06e4239..5e9e41cc632 100644 --- a/packages/agent-core-v2/src/_base/di/instantiation.ts +++ b/packages/agent-core-v2/src/_base/di/instantiation.ts @@ -2,8 +2,9 @@ * `di` domain — service identifiers, `createDecorator`, and the `IInstantiationService` contract. */ -import type { SyncDescriptor0 } from './descriptors'; -import type { DisposableStore } from './lifecycle'; +import type { SyncDescriptor, SyncDescriptor0 } from './descriptors'; +import type { CascadeEngine } from './cascadeEngine'; +import type { DisposableStore, IDisposable } from './lifecycle'; import type { ServiceCollection } from './serviceCollection'; // eslint-disable-next-line @typescript-eslint/no-namespace @@ -109,9 +110,32 @@ export interface ServicesAccessor { get(id: ServiceIdentifier): T; } +export interface ProvideOptions { + /** Cascade-line metadata (L4): a pinned unit never joins a cascade. */ + readonly pinned?: boolean; + /** + * `eager` (default): the unit activates as soon as its dependencies are + * satisfied. `ondemand`: it materializes at first resolution (a cascade-torn + * unit always rebuilds regardless). + */ + readonly activation?: 'eager' | 'ondemand'; +} + +/** + * Handle to one `provide` registration: it is an entry in the provider's + * ledger, so disposing the handle unprovides the token. (Grows into the full + * FiberHandle — thenable / state / update — in Phase 3.) + */ +export interface ProvideHandle extends IDisposable { + readonly uid: number; +} + export interface IInstantiationService { readonly _serviceBrand: undefined; + /** Cascade engine (L2): per-container facade over the tree-wide orchestrated transactions. */ + readonly cascade: CascadeEngine; + invokeFunction( fn: (accessor: ServicesAccessor, ...args: TS) => R, ...args: TS @@ -129,6 +153,17 @@ export interface IInstantiationService { ...args: GetLeadingNonServiceArgs> ): R; createChild(services: ServiceCollection, store?: DisposableStore): IInstantiationService; + /** + * Register (or replace) a token at runtime. Replacing retires the previous + * materialized instance before the new generation becomes visible. + */ + provide( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + options?: ProvideOptions, + ): ProvideHandle; + /** Remove a token, retiring its materialized instance. No-op when absent. */ + unprovide(id: ServiceIdentifier): void; dispose(): void; } diff --git a/packages/agent-core-v2/src/_base/di/instantiationService.ts b/packages/agent-core-v2/src/_base/di/instantiationService.ts index dd4a6a30ff2..23503170673 100644 --- a/packages/agent-core-v2/src/_base/di/instantiationService.ts +++ b/packages/agent-core-v2/src/_base/di/instantiationService.ts @@ -3,21 +3,22 @@ */ import { SyncDescriptor } from './descriptors'; -import { CyclicDependencyError } from './errors'; +import { CascadeEngine, CascadeTree, type CascadeChange, type CascadeHost } from './cascadeEngine'; +import { DependencyGraph } from './dependencyGraph'; +import { CascadeConflictError, CyclicDependencyError } from './errors'; import { Graph } from './graph'; import { IInstantiationService as IInstantiationServiceDecorator, _util, type IInstantiationService, + type ProvideHandle, + type ProvideOptions, type ServiceIdentifier, type ServicesAccessor, } from './instantiation'; -import { - dispose, - isDisposable, - type DisposableStore, - type IDisposable, -} from './lifecycle'; +import { isDisposable, type DisposableStore } from './lifecycle'; +import { onUnexpectedError } from '../errors/unexpectedError'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; import { ServiceCollection } from './serviceCollection'; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -113,8 +114,29 @@ export class InstantiationService implements IInstantiationService { protected readonly _parent?: InstantiationService; + protected readonly _ledger = new Ledger('InstantiationService'); + + /** Tree-global persistent dependency graph (shared by the whole scope tree). */ + get dependencyGraph(): DependencyGraph { + return this._tree.graph; + } + + private readonly _tree: CascadeTree; + + /** Cascade engine (L2): one per container; tree-wide orchestrated transactions. */ + readonly cascade: CascadeEngine; + + private _parentLedgerEntry: LedgerEntry | undefined; + + /** Materialized instance → its ledger entry (for individual retirement). */ + private readonly _instanceEntries = new Map(); + + /** Token → the ledger entry of its latest provide (generation-guarded). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected readonly _constructionOrder: any[] = []; + private readonly _provideEntries = new Map, LedgerEntry>(); + + /** Set while the cascade engine itself resolves — bypasses the in-flight guard. */ + private _cascadeResolving = false; protected readonly _children = new Set(); @@ -124,9 +146,6 @@ export class InstantiationService implements IInstantiationService { // eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly _activeInstantiations = new Set>(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly _servicesToMaybeDispose = new Set(); - private _disposed = false; constructor( @@ -138,6 +157,65 @@ export class InstantiationService implements IInstantiationService { this._parent = parent; this._globalGraph = _enableTracing ? parent?._globalGraph ?? new Graph(e => e) : undefined; this._services.set(IInstantiationServiceDecorator, this); + this._tree = parent?._tree ?? new CascadeTree(new DependencyGraph()); + const host: CascadeHost = { + isRegistered: (token) => this._getServiceInstanceOrDescriptor(token) !== undefined, + ownerScopeOf: (token) => this._ownerOf(token), + isMaterialized: (token) => { + const value = this._services.get(token); + return value !== undefined && !(value instanceof SyncDescriptor); + }, + materialize: (token) => { + this._cascadeResolving = true; + try { + return this._getOrCreateServiceInstance( + token, + Trace.traceCreation(false, CascadeEngine), + ); + } finally { + this._cascadeResolving = false; + } + }, + retire: (token) => this._retireUnit(token), + applyProvide: (token, descriptor, pinned) => { + this._services.set(token, descriptor, { pinned }); + return this._services.uidOf(token)!; + }, + applyProvideInstance: (token, instance, pinned) => { + this._services.set(token, instance, { pinned }); + return this._services.uidOf(token)!; + }, + applyUnprovide: (token) => { + this._services.delete(token); + }, + recipeOf: (token) => { + const entry = this._services.entry(token); + if (entry === undefined) return undefined; + return entry.value instanceof SyncDescriptor ? entry.value : entry.recipe; + }, + dependenciesOf: (recipe) => + _util.getServiceDependencies(recipe.ctor).map((dependency) => dependency.id), + }; + this.cascade = new CascadeEngine(host, this, this._tree); + } + + /** Structural handle for the cascade engine's scoped tokens. */ + get cascadeDisposed(): boolean { + return this._disposed; + } + + /** Distance from the tree root (root = 0). */ + get cascadeDepth(): number { + return (this._parent?.cascadeDepth ?? -1) + 1; + } + + /** The container owning a token in this container's resolution chain. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _ownerOf(id: ServiceIdentifier): InstantiationService | undefined { + if (this._services.has(id)) { + return this; + } + return this._parent?._ownerOf(id); } invokeFunction( @@ -169,6 +247,125 @@ export class InstantiationService implements IInstantiationService { } } + provide( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + options?: ProvideOptions, + ): ProvideHandle { + this._assertNotDisposed(); + this._releaseProvideEntry(id); + + if ( + !(instanceOrDescriptor instanceof SyncDescriptor) && + this._services.get(id) === instanceOrDescriptor + ) { + // Re-affirming the very instance already materialized under this token: + // refresh the registration, no retirement, no cascade. + this._services.set(id, instanceOrDescriptor, { pinned: options?.pinned }); + const uid = this._services.uidOf(id)!; + const entry = this._ledger.register(() => { + if (this._services.uidOf(id) === uid) { + this.unprovide(id); + } + }, `provide:${String(id)}`); + this._provideEntries.set(id, entry); + return { + uid, + dispose: () => { + void entry.dispose(); + }, + }; + } + + // Everything else — a recipe or a replacing instance — is one cascade + // transaction, so live dependents are torn down and rebuilt (D1/D4). + const beforeUid = this._services.uidOf(id); + let appliedUid: number | undefined; + const noteApplied = (): void => { + const uid = this._services.uidOf(id); + if (uid !== undefined && uid !== beforeUid) { + appliedUid = uid; + } + }; + const change: CascadeChange = + instanceOrDescriptor instanceof SyncDescriptor + ? { + action: 'provide', + token: id, + descriptor: instanceOrDescriptor, + pinned: options?.pinned, + activation: options?.activation, + reason: `provide ${String(id)}`, + } + : { + action: 'provide', + token: id, + instance: instanceOrDescriptor, + pinned: options?.pinned, + reason: `provide ${String(id)}`, + }; + noteApplied(); + this.cascade.submit(change).then(noteApplied, onUnexpectedError); + noteApplied(); // the sync fast path has already applied the change + const entry = this._ledger.register(() => { + // Generation guard: only unprovide the generation this entry provided. + if (appliedUid !== undefined && this._services.uidOf(id) === appliedUid) { + this.unprovide(id); + } + }, `provide:${String(id)}`); + this._provideEntries.set(id, entry); + return { + get uid(): number { + if (appliedUid === undefined) { + throw new Error( + `provide of '${String(id)}' has not been applied yet (cascade in flight)`, + ); + } + return appliedUid; + }, + dispose: () => { + void entry.dispose(); + }, + }; + } + + unprovide(id: ServiceIdentifier): void { + if (this._disposed) { + return; + } + this._releaseProvideEntry(id); + if (this._services.get(id) === undefined) { + return; + } + this.cascade + .submit({ action: 'unprovide', token: id, reason: `unprovide ${String(id)}` }) + .catch(onUnexpectedError); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _releaseProvideEntry(id: ServiceIdentifier): void { + const entry = this._provideEntries.get(id); + if (entry !== undefined) { + this._provideEntries.delete(id); + entry.release(); + } + } + + /** Retire the live instance of a token and reset its entry to the recipe. */ + private _retireUnit(id: ServiceIdentifier): void | Promise { + const instance = this._services.get(id); + if (instance === undefined || instance instanceof SyncDescriptor) { + return undefined; + } + this._services.unmaterialize(id); + const entry = this._instanceEntries.get(instance); + if (entry === undefined) { + return undefined; + } + this._instanceEntries.delete(instance); + return entry.dispose(); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any createInstance(descriptor: SyncDescriptor, ...rest: any[]): T; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -204,42 +401,40 @@ export class InstantiationService implements IInstantiationService { 'createChild requires a ServiceCollection instance (got something else)', ); } - const child = new InstantiationService(services, this._strict, this, this._enableTracing); + const child = this._createChildService(services); this._children.add(child); + child._parentLedgerEntry = this._ledger.register(() => { + child.dispose(); + }, 'child-instantiation'); store?.add(child); return child; } + protected _createChildService(services: ServiceCollection): InstantiationService { + return new InstantiationService(services, this._strict, this, this._enableTracing); + } + dispose(): void { if (this._disposed) { return; } this._disposed = true; - const childSnapshot = Array.from(this._children); - this._children.clear(); - - const ownInstances: IDisposable[] = []; - for (let i = this._constructionOrder.length - 1; i >= 0; i--) { - const instance = this._constructionOrder[i]!; - if (isDisposable(instance)) { - ownInstances.push(instance); - this._servicesToMaybeDispose.delete(instance); - } - } - - const remainingInstances: IDisposable[] = []; - for (const candidate of this._servicesToMaybeDispose) { - if (isDisposable(candidate)) { - remainingInstances.push(candidate); - } - } - try { - dispose([...childSnapshot, ...ownInstances, ...remainingInstances]); + // Children first (forward creation order): their services may depend on + // this container's instances, so they must die before them. Each child + // releases its ledger entry, so the ledger teardown below skips them. + for (const child of Array.from(this._children)) { + child.dispose(); + } + this._children.clear(); + void this._ledger.teardown('scope-close'); + this._services.dispose(); + this.cascade.dispose(); } finally { - this._constructionOrder.length = 0; - this._servicesToMaybeDispose.clear(); + this._children.clear(); + this._parentLedgerEntry?.release(); + this._parentLedgerEntry = undefined; if (this._parent) { this._parent._children.delete(this); } @@ -281,6 +476,21 @@ export class InstantiationService implements IInstantiationService { } protected _getOrCreateServiceInstance(id: ServiceIdentifier, _trace: Trace): T { + if (!this._cascadeResolving) { + if (this.cascade.isInFlight(id)) { + // The sync resolution path cannot suspend; the async path + // (cascade.resolveWhenAvailable) waits for the transaction instead. + throw new CascadeConflictError( + String(id), + 'token is inside an in-flight cascade transaction', + ); + } + const failure = this.cascade.unitFailure(id); + if (failure !== undefined) { + // D5: Failed is sticky — resolving a failed unit rethrows its error. + throw failure as Error; + } + } const entry = this._getServiceInstanceOrDescriptor(id); if (entry instanceof SyncDescriptor) { @@ -397,13 +607,7 @@ export class InstantiationService implements IInstantiationService { _trace: Trace, ): T { if (this._services.get(id) instanceof SyncDescriptor) { - return this._createServiceInstance( - id, - ctor, - args, - _trace, - this._servicesToMaybeDispose, - ); + return this._createServiceInstance(id, ctor, args, _trace); } if (this._parent) { return this._parent._createServiceInstanceWithOwner( @@ -422,15 +626,39 @@ export class InstantiationService implements IInstantiationService { ctor: any, args: ReadonlyArray = [], _trace: Trace, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - disposeBucket: Set, ): T { const root = this._root(); root._inProgress.push(id); try { const result = this._createInstance(ctor, args.slice(), _trace); - disposeBucket.add(result); - this._constructionOrder.push(result); + // Persistent tree-global graph: record the instance and its + // constructor-injection (instance) edges, both ends scope-tagged; the + // ledger entry removes them again at teardown. Edges point child → + // parent (a dependency's owner is always this container or an ancestor). + this.dependencyGraph.addInstance(result as object, this, id); + for (const dependency of _util.getServiceDependencies(ctor)) { + const owner = this._ownerOf(dependency.id); + if (owner !== undefined) { + this.dependencyGraph.addEdge( + result as object, + { scope: owner, token: dependency.id }, + 'instance', + ); + } + } + const entry = this._ledger.register(() => { + this._instanceEntries.delete(result); + this.dependencyGraph.removeInstance(result as object); + if (isDisposable(result)) { + // Propagate a (runtime) async disposer so cascade teardown can + // await it serially; statically `dispose()` is typed void. + const out = result.dispose() as unknown as void | Promise; + return out; + } + return undefined; + }, `service:${String(id)}`); + this._instanceEntries.set(result, entry); + this.cascade.observedMaterialization(id); return result; } finally { const popIdx = root._inProgress.lastIndexOf(id); @@ -442,7 +670,9 @@ export class InstantiationService implements IInstantiationService { private _setCreatedServiceInstance(id: ServiceIdentifier, instance: T): void { if (this._services.get(id) instanceof SyncDescriptor) { - this._services.set(id, instance); + // Keeps the recipe on the entry so a cascade teardown can unmaterialize + // back to it (and rebuild later). + this._services.materialize(id, instance); } else if (this._parent) { this._parent._setCreatedServiceInstance(id, instance); } else { diff --git a/packages/agent-core-v2/src/_base/di/lifecycle.ts b/packages/agent-core-v2/src/_base/di/lifecycle.ts index ed294b4d660..0593c3c8290 100644 --- a/packages/agent-core-v2/src/_base/di/lifecycle.ts +++ b/packages/agent-core-v2/src/_base/di/lifecycle.ts @@ -3,6 +3,11 @@ */ import { onUnexpectedError } from '../errors/unexpectedError'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; + +function disposableLabel(d: IDisposable): string { + return `disposable:${d.constructor?.name ?? 'anonymous'}`; +} export interface IDisposableTracker { trackDisposable(disposable: IDisposable): void; @@ -220,7 +225,8 @@ export function combinedDisposable(...disposables: IDisposable[]): IDisposable { } export class DisposableStore implements IDisposable { - private readonly _toDispose = new Set(); + private readonly _ledger = new Ledger('DisposableStore'); + private readonly _entries = new Map(); private _isDisposed = false; constructor() { @@ -236,7 +242,14 @@ export class DisposableStore implements IDisposable { d.dispose(); return d; } - this._toDispose.add(d); + if (!this._entries.has(d)) { + this._entries.set( + d, + this._ledger.register(() => { + d.dispose(); + }, disposableLabel(d)), + ); + } return d; } @@ -245,23 +258,30 @@ export class DisposableStore implements IDisposable { if ((d as unknown as DisposableStore) === this) { throw new Error('Cannot dispose a disposable on itself!'); } - this._toDispose.delete(d); + const entry = this._entries.get(d); + if (entry) { + this._entries.delete(d); + entry.release(); + } d.dispose(); } deleteAndLeak(d: T): void { if (this._isDisposed) return; - if (this._toDispose.delete(d)) { + const entry = this._entries.get(d); + if (entry) { + this._entries.delete(d); + entry.release(); setParentOfDisposable(d, null); } } clear(): void { - if (this._toDispose.size === 0) return; + if (this._entries.size === 0) return; try { - dispose(this._toDispose); + void this._ledger.clear('scope-close'); } finally { - this._toDispose.clear(); + this._entries.clear(); } } @@ -269,7 +289,11 @@ export class DisposableStore implements IDisposable { if (this._isDisposed) return; this._isDisposed = true; markAsDisposed(this); - this.clear(); + try { + void this._ledger.teardown('scope-close'); + } finally { + this._entries.clear(); + } } get isDisposed(): boolean { @@ -312,6 +336,8 @@ export namespace Disposable { } export class MutableDisposable implements IDisposable { + private readonly _ledger = new Ledger('MutableDisposable'); + private _entry: LedgerEntry | undefined; private _value: T | undefined; private _isDisposed = false; @@ -331,20 +357,31 @@ export class MutableDisposable implements IDisposable { return; } if (this._value === value) return; + this._entry?.release(); + this._entry = undefined; this._value?.dispose(); if (value) setParentOfDisposable(value, this); this._value = value; + if (value) { + this._entry = this._ledger.register(() => { + value.dispose(); + }, disposableLabel(value)); + } } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; markAsDisposed(this); + const entry = this._entry; const prev = this._value; + this._entry = undefined; + this._value = undefined; + entry?.release(); + void this._ledger.teardown('scope-close'); if (prev !== undefined) { prev.dispose(); } - this._value = undefined; } clear(): void { @@ -356,6 +393,8 @@ export class MutableDisposable implements IDisposable { if (this._isDisposed) return undefined; const prev = this._value; this._value = undefined; + this._entry?.release(); + this._entry = undefined; if (prev !== undefined) setParentOfDisposable(prev, null); return prev; } diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index 8d2db5bb138..347293e7e0d 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -9,6 +9,7 @@ import { SyncDescriptor } from './descriptors'; import type { ServiceIdentifier, ServicesAccessor, IInstantiationService } from './instantiation'; import { InstantiationService } from './instantiationService'; import { DisposableStore, type IDisposable } from './lifecycle'; +import { Ledger, type LedgerEntry } from '../lifecycle/ledger'; import { ServiceCollection } from './serviceCollection'; export enum LifecycleScope { @@ -139,6 +140,8 @@ export class Scope implements IDisposable { readonly accessor: ServicesAccessor; private readonly _store = new DisposableStore(); + private readonly _ledger: Ledger; + private _ledgerEntry: LedgerEntry | undefined; private _disposed = false; private constructor( @@ -147,6 +150,15 @@ export class Scope implements IDisposable { readonly instantiation: IInstantiationService, private readonly _parent?: Scope, ) { + // Registration order is reversed at teardown: children (registered later) + // go first, then the store, then the instantiation container. + this._ledger = new Ledger(`scope:${id}`); + this._ledger.register(() => { + this.instantiation.dispose(); + }, 'instantiation'); + this._ledger.register(() => { + this._store.dispose(); + }, 'store'); this.accessor = { get: (serviceId: ServiceIdentifier): T => instantiation.invokeFunction((a) => a.get(serviceId)), @@ -192,6 +204,9 @@ export class Scope implements IDisposable { } const child = new Scope(id, kind, childInstantiation, this); this.children.set(id, child); + child._ledgerEntry = this._ledger.register(() => { + child.dispose(); + }, `scope:${id}`); return child; } @@ -205,17 +220,15 @@ export class Scope implements IDisposable { } this._disposed = true; - const kids = Array.from(this.children.values()); - this.children.clear(); - for (const child of kids) { - child.dispose(); - } - - this._store.dispose(); - this.instantiation.dispose(); - - if (this._parent) { - this._parent.children.delete(this.id); + this._ledgerEntry?.release(); + this._ledgerEntry = undefined; + try { + void this._ledger.teardown('scope-close'); + } finally { + this.children.clear(); + if (this._parent) { + this._parent.children.delete(this.id); + } } } } diff --git a/packages/agent-core-v2/src/_base/di/serviceCollection.ts b/packages/agent-core-v2/src/_base/di/serviceCollection.ts index 13fb2803a0c..2f81b203bd6 100644 --- a/packages/agent-core-v2/src/_base/di/serviceCollection.ts +++ b/packages/agent-core-v2/src/_base/di/serviceCollection.ts @@ -1,30 +1,127 @@ /** - * `di` domain — `ServiceCollection` map of service id → descriptor or instance. + * `di` domain — `ServiceCollection`: the dynamic registry (L1). + * + * Maps a service id to its recipe (`SyncDescriptor`) or materialized instance. + * Every write stamps the entry with a container-monotonic `uid` (a generation + * marker used for introspection and history — it plays no role in change + * detection) and fires the token's availability event with `{ oldUid, newUid }`. */ -import type { SyncDescriptor } from './descriptors'; +import { Emitter } from '../event'; +import { SyncDescriptor } from './descriptors'; import type { ServiceIdentifier } from './instantiation'; +import type { IDisposable } from './lifecycle'; + +export interface ServiceCollectionEntry { + readonly value: T | SyncDescriptor; + readonly uid: number; + readonly pinned: boolean; + /** The recipe a materialized instance was created from (kept for rebuilds). */ + readonly recipe?: SyncDescriptor; +} + +export interface AvailabilityChange { + readonly oldUid: number | undefined; + readonly newUid: number | undefined; +} export class ServiceCollection { // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly _entries = new Map, unknown>(); + private readonly _entries = new Map, ServiceCollectionEntry>(); + private readonly _emitters = new Map< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ServiceIdentifier, + Emitter + >(); + private _nextUid = 0; constructor( // eslint-disable-next-line @typescript-eslint/no-explicit-any ...entries: ReadonlyArray, unknown]> ) { for (const [id, value] of entries) { - this._entries.set(id, value); + this.set(id, value); } } set( id: ServiceIdentifier, instanceOrDescriptor: T | SyncDescriptor, + options?: { readonly pinned?: boolean }, ): T | SyncDescriptor | undefined { const prev = this._entries.get(id); - this._entries.set(id, instanceOrDescriptor); - return prev as T | SyncDescriptor | undefined; + const uid = ++this._nextUid; + this._entries.set(id, { + value: instanceOrDescriptor, + uid, + pinned: options?.pinned ?? prev?.pinned ?? false, + }); + this._emitterFor(id).fire({ oldUid: prev?.uid, newUid: uid }); + return prev?.value as T | SyncDescriptor | undefined; + } + + /** + * Swap a descriptor entry for its materialized instance, keeping the uid, + * pinned flag, and the recipe (so the entry can be unmaterialized back to + * the recipe when the instance is torn down). Not a new generation. + */ + materialize(id: ServiceIdentifier, instance: T): void { + const prev = this._entries.get(id); + if (prev === undefined || !(prev.value instanceof SyncDescriptor)) { + return; + } + this._entries.set(id, { + value: instance, + uid: prev.uid, + pinned: prev.pinned, + recipe: prev.value, + }); + } + + /** Swap a materialized entry back to its recipe (instance torn down). */ + unmaterialize(id: ServiceIdentifier): void { + const prev = this._entries.get(id); + if (prev === undefined || prev.recipe === undefined) { + return; + } + this._entries.set(id, { + value: prev.recipe, + uid: prev.uid, + pinned: prev.pinned, + }); + } + + delete(id: ServiceIdentifier): T | SyncDescriptor | undefined { + const prev = this._entries.get(id); + if (prev === undefined) { + return undefined; + } + this._entries.delete(id); + this._emitterFor(id).fire({ oldUid: prev.uid, newUid: undefined }); + return prev.value as T | SyncDescriptor | undefined; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entry(id: ServiceIdentifier): ServiceCollectionEntry | undefined { + return this._entries.get(id); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + uidOf(id: ServiceIdentifier): number | undefined { + return this._entries.get(id)?.uid; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isPinned(id: ServiceIdentifier): boolean { + return this._entries.get(id)?.pinned ?? false; + } + + /** Fired when the token's availability changes (set → new uid, delete → undefined). */ + onDidChange( + id: ServiceIdentifier, + listener: (change: AvailabilityChange) => void, + ): IDisposable { + return this._emitterFor(id).event(listener); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -33,7 +130,7 @@ export class ServiceCollection { } get(id: ServiceIdentifier): T | SyncDescriptor | undefined { - return this._entries.get(id) as T | SyncDescriptor | undefined; + return this._entries.get(id)?.value as T | SyncDescriptor | undefined; } forEach( @@ -43,6 +140,22 @@ export class ServiceCollection { value: unknown, ) => void, ): void { - this._entries.forEach((value, id) => callback(id, value)); + this._entries.forEach((entry, id) => { callback(id, entry.value); }); + } + + dispose(): void { + for (const emitter of this._emitters.values()) { + emitter.dispose(); + } + this._emitters.clear(); + } + + private _emitterFor(id: ServiceIdentifier): Emitter { + let emitter = this._emitters.get(id); + if (emitter === undefined) { + emitter = new Emitter(); + this._emitters.set(id, emitter); + } + return emitter; } } diff --git a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts index f0b51a971b8..258db38e024 100644 --- a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts +++ b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts @@ -53,7 +53,12 @@ export class TestInstantiationService extends InstantiationService implements ID id: ServiceIdentifier, instanceOrDescriptor: T | SyncDescriptor, ): T | SyncDescriptor | undefined { - return this._serviceCollection.set(id, instanceOrDescriptor); + // Routed through provide so test overrides get production semantics: + // a replaced materialized instance is retired, a new generation starts. + // Descriptors stay lazy (constructed at first resolution), as before. + const prev = this._serviceCollection.get(id); + this.provide(id, instanceOrDescriptor, { activation: 'ondemand' }); + return prev; } public mock(id: ServiceIdentifier): T | sinon.SinonMock { @@ -269,14 +274,11 @@ export class TestInstantiationService extends InstantiationService implements ID } public override createChild(services: ServiceCollection): TestInstantiationService { - if (!(services instanceof ServiceCollection)) { - throw new TypeError( - 'createChild requires a ServiceCollection instance (got something else)', - ); - } - const child = new TestInstantiationService(services, false, this); - (this as unknown as { _children: Set })._children.add(child); - return child; + return super.createChild(services) as TestInstantiationService; + } + + protected override _createChildService(services: ServiceCollection): InstantiationService { + return new TestInstantiationService(services, false, this); } public override dispose(): void { diff --git a/packages/agent-core-v2/src/_base/lifecycle/disposer.ts b/packages/agent-core-v2/src/_base/lifecycle/disposer.ts new file mode 100644 index 00000000000..1278dd77d23 --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/disposer.ts @@ -0,0 +1,56 @@ +/** + * `_base.lifecycle` — disposer types shared by the Ledger. + * + * A `Disposer` undoes one registered side effect. Disposers are dual-track + * (sync / async), mirroring ES explicit resource management: a Ledger whose + * entries are all synchronous tears down within a single tick; any async + * entry suspends the teardown promise until it settles. + */ + +/** Why the ledger is being torn down; threaded through to every disposer. */ +export type TeardownReason = 'scope-close' | 'cascade' | 'unload'; + +export type Disposer = (reason: TeardownReason) => void | Promise; + +/** + * The four return forms accepted from an effect body: + * - `void` — nothing to roll back (the entry still exists for introspection); + * - `Disposer` — a single rollback action; + * - `Promise` — an asynchronously produced rollback action; + * - sync / async iterator of `Disposer`s — each yielded value is one rollback + * action; if iteration throws partway, the already-yielded disposers are + * rolled back in reverse before the error propagates. + */ +export type EffectResult = + | void + | Disposer + | Promise + | Iterable + | AsyncIterable; + +export type EffectBody = () => EffectResult; + +export function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +export function isSyncIterable(value: unknown): value is Iterable { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function' + ); +} + +export function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === + 'function' + ); +} diff --git a/packages/agent-core-v2/src/_base/lifecycle/errors.ts b/packages/agent-core-v2/src/_base/lifecycle/errors.ts new file mode 100644 index 00000000000..4ea32c5d982 --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/errors.ts @@ -0,0 +1,16 @@ +/** + * `_base.lifecycle` — Ledger errors. + */ + +export class LedgerDisposedError extends Error { + constructor( + readonly ledgerLabel: string, + readonly operation: string, + readonly ledgerState: 'disposing' | 'disposed', + ) { + super( + `Ledger '${ledgerLabel}' is ${ledgerState}: cannot ${operation} on a ${ledgerState} ledger`, + ); + this.name = 'LedgerDisposedError'; + } +} diff --git a/packages/agent-core-v2/src/_base/lifecycle/index.ts b/packages/agent-core-v2/src/_base/lifecycle/index.ts new file mode 100644 index 00000000000..0fcdf1c5205 --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/index.ts @@ -0,0 +1,4 @@ +export * from './disposer'; +export * from './errors'; +export * from './ledger'; +export * from './lifecycleMachine'; diff --git a/packages/agent-core-v2/src/_base/lifecycle/ledger.ts b/packages/agent-core-v2/src/_base/lifecycle/ledger.ts new file mode 100644 index 00000000000..3dd4846a54d --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/ledger.ts @@ -0,0 +1,352 @@ +/** + * `_base.lifecycle` — `Ledger`: an ordered book of rollbackable registrations. + * + * A Ledger records entries (disposers, effects, child ledgers) in registration + * order and tears them down in strict reverse order, awaiting each entry + * serially — never in parallel. Rollback is uninterruptible: a failing entry + * is logged (with its label) and teardown continues. Registering into a + * disposing/disposed ledger throws immediately. + * + * The Ledger knows nothing about DI; scopes and containers build on top of it. + */ + +import { onUnexpectedError } from '../errors/unexpectedError'; +import { + isAsyncIterable, + isPromiseLike, + isSyncIterable, + type Disposer, + type EffectBody, + type TeardownReason, +} from './disposer'; +import { LedgerDisposedError } from './errors'; + +export type LedgerState = 'active' | 'disposing' | 'disposed'; + +export interface LedgerEntryInfo { + readonly label: string; + readonly kind: 'disposer' | 'effect' | 'ledger'; + readonly stack?: string; + readonly children?: readonly LedgerEntryInfo[]; +} + +/** Handle to one ledger entry: remove it, or remove-and-run it. */ +export interface LedgerEntry { + readonly label: string; + /** True once the entry has been disposed, released, or torn down. */ + readonly disposed: boolean; + /** Remove the entry and run its disposer (guarded). Idempotent. */ + dispose(reason?: TeardownReason): void | Promise; + /** Remove the entry without running its disposer. Idempotent. */ + release(): void; +} + +interface EntryRecord { + label: string; + kind: 'disposer' | 'effect' | 'ledger'; + stack?: string; + active: boolean; + run: Disposer; + /** Set for child-ledger entries, for introspection. */ + ledger?: Ledger; +} + +export class Ledger { + /** Dev-mode toggle: capture the registration stack on every entry. */ + static captureStacks = false; + + private _state: LedgerState = 'active'; + private readonly _records: EntryRecord[] = []; + private _teardownPromise: Promise | undefined; + private _parentEntry: LedgerEntry | undefined; + + constructor(readonly label: string = 'ledger') {} + + get state(): LedgerState { + return this._state; + } + + get isActive(): boolean { + return this._state === 'active'; + } + + get isDisposed(): boolean { + return this._state === 'disposed'; + } + + /** Number of live entries. */ + get size(): number { + return this._records.reduce((count, record) => count + (record.active ? 1 : 0), 0); + } + + register(disposer: Disposer, label: string = 'disposer'): LedgerEntry { + this._assertActive('register'); + return this._push({ label, kind: 'disposer', active: true, run: disposer }); + } + + effect(body: EffectBody, label: string = 'effect'): LedgerEntry { + this._assertActive('effect'); + const out = body(); + if (typeof out === 'function') { + return this._push({ label, kind: 'effect', active: true, run: out }); + } + if (isPromiseLike(out)) { + const promise = Promise.resolve(out); + return this._push({ + label, + kind: 'effect', + active: true, + run: async (reason) => { + const disposer = await promise; + if (typeof disposer === 'function') await disposer(reason); + }, + }); + } + if (isAsyncIterable(out)) { + const promise = driveAsyncEffect(out); + return this._push({ + label, + kind: 'effect', + active: true, + run: async (reason) => { + const disposer = await promise; + await disposer(reason); + }, + }); + } + if (isSyncIterable(out)) { + // Drives the iterator immediately; a mid-iteration throw rolls back the + // already-yielded disposers before rethrowing (construction failure). + const run = driveSyncEffect(out); + return this._push({ label, kind: 'effect', active: true, run }); + } + return this._push({ label, kind: 'effect', active: true, run: () => {} }); + } + + /** A child ledger is itself one entry of this ledger. */ + createChild(label: string = 'ledger'): Ledger { + this._assertActive('createChild'); + const child = new Ledger(label); + child._parentEntry = this._push({ + label, + kind: 'ledger', + active: true, + run: (reason) => child.teardown(reason), + ledger: child, + }); + return child; + } + + /** + * Tear down every entry in strict reverse registration order, awaiting each + * one serially. Idempotent: a second call while disposing returns the + * in-flight promise; after disposal it is a no-op. Returns `undefined` when + * every entry completed synchronously (state is then synchronously + * `disposed`), otherwise a promise that settles once teardown completes. + */ + teardown(reason: TeardownReason = 'scope-close'): void | Promise { + if (this._state !== 'active') { + return this._teardownPromise; + } + this._state = 'disposing'; + this._detachFromParent(); + const out = drainRecords(this._records, reason); + if (isPromiseLike(out)) { + this._teardownPromise = Promise.resolve(out).then(() => { + this._state = 'disposed'; + }); + return this._teardownPromise; + } + this._state = 'disposed'; + return undefined; + } + + /** Tear down all current entries but keep the ledger active. */ + clear(reason: TeardownReason = 'scope-close'): void | Promise { + this._assertActive('clear'); + return drainRecords(this._records, reason); + } + + /** Introspection snapshot of the live entries (child ledgers recurse). */ + entries(): LedgerEntryInfo[] { + const infos: LedgerEntryInfo[] = []; + for (const record of this._records) { + if (!record.active) continue; + infos.push({ + label: record.label, + kind: record.kind, + stack: record.stack, + children: record.ledger?.entries(), + }); + } + return infos; + } + + private _push(record: EntryRecord): LedgerEntry { + if (Ledger.captureStacks) { + record.stack = new Error('Ledger registration').stack; + } + this._records.push(record); + return { + label: record.label, + get disposed() { + return !record.active; + }, + dispose: (reason: TeardownReason = 'scope-close') => { + if (!record.active) return undefined; + record.active = false; + this._remove(record); + return runGuarded(record, reason); + }, + release: () => { + if (!record.active) return; + record.active = false; + this._remove(record); + }, + }; + } + + private _remove(record: EntryRecord): void { + const index = this._records.indexOf(record); + if (index >= 0) { + this._records.splice(index, 1); + } + } + + /** Called by a child ledger when it tears itself down. */ + private _detachFromParent(): void { + this._parentEntry?.release(); + this._parentEntry = undefined; + } + + private _assertActive(operation: string): void { + if (this._state !== 'active') { + throw new LedgerDisposedError(this.label, operation, this._state); + } + } +} + +/** Run one entry's disposer, logging (with label) instead of throwing. */ +function runGuarded(record: EntryRecord, reason: TeardownReason): void | Promise { + let out: void | Promise; + try { + out = record.run(reason); + } catch (error) { + onUnexpectedError(tagged(error, record.label)); + return undefined; + } + if (isPromiseLike(out)) { + return Promise.resolve(out).catch((error: unknown) => { + onUnexpectedError(tagged(error, record.label)); + }); + } + return undefined; +} + +function tagged(error: unknown, label: string): unknown { + if (error instanceof Error) { + error.message = `[ledger:${label}] ${error.message}`; + return error; + } + return new Error(`[ledger:${label}] ${String(error)}`); +} + +/** + * Tear down a record list from the tail, serially. Sync fast path: when no + * entry returns a promise, the whole drain completes within the tick. + */ +function drainRecords(records: EntryRecord[], reason: TeardownReason): void | Promise { + let index = records.length; + const step = (): void | Promise => { + while (index > 0) { + index -= 1; + const record = records[index]!; + if (!record.active) continue; + record.active = false; + const out = runGuarded(record, reason); + if (isPromiseLike(out)) { + return Promise.resolve(out).then(step); + } + } + records.length = 0; + return undefined; + }; + return step(); +} + +/** Run a fixed disposer list in reverse, serially, guarding each entry. */ +function runDisposersReverse( + disposers: readonly Disposer[], + reason: TeardownReason, +): void | Promise { + let index = disposers.length; + const step = (): void | Promise => { + while (index > 0) { + index -= 1; + const disposer = disposers[index]!; + let out: void | Promise; + try { + out = disposer(reason); + } catch (error) { + onUnexpectedError(tagged(error, 'effect')); + continue; + } + if (isPromiseLike(out)) { + return Promise.resolve(out) + .catch((error: unknown) => { onUnexpectedError(tagged(error, 'effect')); }) + .then(step); + } + } + return undefined; + }; + return step(); +} + +function collect(step: IteratorResult, disposers: Disposer[]): void { + if (typeof step.value === 'function') { + disposers.push(step.value); + } +} + +/** + * Drive a sync effect iterator to completion now. On a mid-iteration throw, + * the already-yielded disposers are rolled back in reverse (sync fast path; + * an async rollback continues in the background) and the error is rethrown. + */ +function driveSyncEffect(iterable: Iterable): Disposer { + const disposers: Disposer[] = []; + const iterator = iterable[Symbol.iterator](); + try { + let step = iterator.next(); + while (!step.done) { + collect(step, disposers); + step = iterator.next(); + } + collect(step, disposers); + } catch (error) { + const rollback = runDisposersReverse(disposers, 'unload'); + if (isPromiseLike(rollback)) { + Promise.resolve(rollback).catch((error: unknown) => { onUnexpectedError(error); }); + } + throw error; + } + return (reason) => runDisposersReverse(disposers, reason); +} + +/** Async counterpart of {@link driveSyncEffect}; resolves to the composite disposer. */ +async function driveAsyncEffect(iterable: AsyncIterable): Promise { + const disposers: Disposer[] = []; + const iterator = iterable[Symbol.asyncIterator](); + try { + let step = await iterator.next(); + while (!step.done) { + collect(step, disposers); + step = await iterator.next(); + } + collect(step, disposers); + } catch (error) { + await runDisposersReverse(disposers, 'unload'); + throw error; + } + return (reason) => runDisposersReverse(disposers, reason); +} diff --git a/packages/agent-core-v2/test/_base/di/cascade.test.ts b/packages/agent-core-v2/test/_base/di/cascade.test.ts new file mode 100644 index 00000000000..a24ea8e9e1f --- /dev/null +++ b/packages/agent-core-v2/test/_base/di/cascade.test.ts @@ -0,0 +1,653 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { CascadeConflictError } from '#/_base/di/errors'; +import { createDecorator } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import type { Ledger } from '#/_base/lifecycle/ledger'; + +function deferred(): { + promise: Promise; + resolve: (value?: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value?: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res as (value?: T | PromiseLike) => void; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function ledgerOf(ix: InstantiationService): Ledger { + return (ix as unknown as { _ledger: Ledger })._ledger; +} + +/** Flush all pending microtask chains (async teardown hops). */ +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +// ------------------------------------------------------------------ fixtures + +interface IRoot { + label: string; +} +const IRoot = createDecorator('cascade-root'); + +interface IMid { + root: IRoot; +} +const IMid = createDecorator('cascade-mid'); + +interface ILeaf { + mid: IMid; +} +const ILeaf = createDecorator('cascade-leaf'); + +interface IExtra { + label: string; +} +const IExtra = createDecorator('cascade-extra'); + +/** Shared per-test event log; fixtures push construct/dispose events. */ +let events: string[] = []; + +class Root implements IRoot { + label = 'root'; + constructor(public readonly tag = 'root') { + events.push(`+${this.tag}`); + } + dispose(): void { + events.push(`-${this.tag}`); + } +} + +class Mid implements IMid { + constructor(@IRoot public readonly root: IRoot) { + events.push('+mid'); + } + dispose(): void { + events.push('-mid'); + } +} + +class Leaf implements ILeaf { + constructor(@IMid public readonly mid: IMid) { + events.push('+leaf'); + } + dispose(): void { + events.push('-leaf'); + } +} + +class Extra implements IExtra { + label = 'extra'; + constructor() { + events.push('+extra'); + } + dispose(): void { + events.push('-extra'); + } +} + +function makeContainer(strict = true): InstantiationService { + return new InstantiationService(new ServiceCollection(), strict); +} + +function provideChain(ix: InstantiationService): void { + ix.provide(IRoot, new SyncDescriptor(Root)); + ix.provide(IMid, new SyncDescriptor(Mid)); + ix.provide(ILeaf, new SyncDescriptor(Leaf)); +} + +afterEach(() => { + events = []; +}); + +describe('cascade engine — mechanism matrix', () => { + it('1. provide X auto-activates dependents from Pending', () => { + const ix = makeContainer(); + events = []; + // Dependent provided before its dependency: goes to the waiting area. + ix.provide(IMid, new SyncDescriptor(Mid)); + expect(ix.cascade.unitState(IMid)).toBe('Pending'); + expect(events).toEqual([]); + + ix.provide(IRoot, new SyncDescriptor(Root)); + expect(ix.cascade.unitState(IRoot)).toBe('Active'); + expect(ix.cascade.unitState(IMid)).toBe('Active'); + expect(events).toEqual(['+root', '+mid']); + ix.dispose(); + }); + + it('2. unprovide X tears transitive dependents down in reverse topo order, back to Pending', () => { + const ix = makeContainer(); + provideChain(ix); + events = []; + const mid = ix.invokeFunction((a) => a.get(IMid)) as Mid; + const leaf = ix.invokeFunction((a) => a.get(ILeaf)) as Leaf; + + ix.unprovide(IRoot); + + expect(events).toEqual(['-leaf', '-mid', '-root']); + expect(ix.cascade.unitState(IRoot)).toBeUndefined(); // removed, not a state + expect(ix.cascade.unitState(IMid)).toBe('Pending'); + expect(ix.cascade.unitState(ILeaf)).toBe('Pending'); + expect(() => ix.invokeFunction((a) => a.get(IRoot))).toThrow(/unknown service/); + // The waiting area retains recipes with the missing token indexed. + expect(ix.cascade.pendingSnapshot().get('cascade-mid')).toEqual(['cascade-root']); + void mid; + void leaf; + ix.dispose(); + }); + + it('3. re-provide rebuilds the waiting area in topo order with fresh instances', () => { + const ix = makeContainer(); + provideChain(ix); + const firstMid = ix.invokeFunction((a) => a.get(IMid)); + const firstLeaf = ix.invokeFunction((a) => a.get(ILeaf)); + ix.unprovide(IRoot); + events = []; + + ix.provide(IRoot, new SyncDescriptor(Root)); + + expect(events).toEqual(['+root', '+mid', '+leaf']); + const secondMid = ix.invokeFunction((a) => a.get(IMid)); + const secondLeaf = ix.invokeFunction((a) => a.get(ILeaf)); + expect(secondMid).not.toBe(firstMid); + expect(secondLeaf).not.toBe(firstLeaf); + expect(secondMid.root).toBe(ix.invokeFunction((a) => a.get(IRoot))); + ix.dispose(); + }); + + it('4. replace is a single transaction: dependents rebuild against the new generation', () => { + const ix = makeContainer(); + provideChain(ix); + const firstMid = ix.invokeFunction((a) => a.get(IMid)); + events = []; + + // Replace the root recipe in one transaction. + class Root2 implements IRoot { + label = 'root2'; + constructor() { + events.push('+root2'); + } + dispose(): void { + events.push('-root2'); + } + } + const historyBefore = ix.cascade.history().length; + ix.provide(IRoot, new SyncDescriptor(Root2)); + + // One transaction covered teardown + rebuild; nothing lingered in Pending. + expect(ix.cascade.history().length).toBe(historyBefore + 1); + const entry = ix.cascade.history().at(-1)!; + expect(entry.tornDown).toEqual(['cascade-leaf', 'cascade-mid', 'cascade-root']); + expect(entry.rebuilt).toEqual(['cascade-root', 'cascade-mid', 'cascade-leaf']); + expect(events).toEqual(['-leaf', '-mid', '-root', '+root2', '+mid', '+leaf']); + expect(ix.cascade.unitState(IMid)).toBe('Active'); + expect(ix.cascade.unitState(ILeaf)).toBe('Active'); + const newMid = ix.invokeFunction((a) => a.get(IMid)); + expect(newMid).not.toBe(firstMid); + expect(newMid.root).toBeInstanceOf(Root2); + ix.dispose(); + }); + + it('5/6. requests submitted during a cascade queue up and merge their contagion sets', async () => { + const ix = makeContainer(); + ix.provide(IRoot, new SyncDescriptor(Root)); + ix.provide(IExtra, new SyncDescriptor(Extra)); + const gate = deferred(); + const hookCalls: string[][] = []; + let calls = 0; + ix.cascade.configure({ + onWillCascade: (affected) => { + calls += 1; + hookCalls.push(affected.map(String)); + // Park only the first transaction at the abort wait. + return calls === 1 ? gate.promise : undefined; + }, + }); + + const first = ix.cascade.submit({ + action: 'unprovide', + token: IRoot, + reason: 'drop root', + }); + // These two queue behind the in-flight transaction and merge into one. + const second = ix.cascade.submit({ + action: 'unprovide', + token: IExtra, + reason: 'drop extra', + }); + const third = ix.cascade.submit({ + action: 'provide', + token: IMid, + descriptor: new SyncDescriptor(Mid), + reason: 'add mid', + }); + + // Queued changes are not applied while the first transaction is in flight. + expect(ix.cascade.isInFlight(IRoot)).toBe(true); + // A token outside the in-flight contagion set resolves normally. + expect(ix.invokeFunction((a) => a.get(IExtra))).toBeInstanceOf(Extra); + + gate.resolve(); + await Promise.all([first, second, third]); + + // Three requests, two transactions: the queued two merged (one hook call each). + expect(calls).toBe(2); + // (The first two history entries are the initial provides.) + const history = ix.cascade.history().slice(-2); + expect(history[0]!.changes).toEqual([{ token: 'cascade-root', action: 'unprovide' }]); + expect(history[1]!.changes).toEqual([ + { token: 'cascade-extra', action: 'unprovide' }, + { token: 'cascade-mid', action: 'provide' }, + ]); + expect(ix.cascade.unitState(IExtra)).toBeUndefined(); + // Mid's dependency is gone: it waits. + expect(ix.cascade.unitState(IMid)).toBe('Pending'); + ix.dispose(); + }); + + it('7. construction failure is sticky Failed; update() reloads', () => { + const ix = makeContainer(); + let shouldThrow = true; + class Flaky implements IExtra { + label = 'flaky'; + constructor() { + if (shouldThrow) { + throw new Error('ctor boom'); + } + events.push('+flaky'); + } + } + ix.provide(IExtra, new SyncDescriptor(Flaky)); + expect(ix.cascade.unitState(IExtra)).toBe('Failed'); + // Resolving a failed unit rethrows the recorded error. + expect(() => ix.invokeFunction((a) => a.get(IExtra))).toThrow('ctor boom'); + + // A dependent of a Failed unit waits. + class NeedsExtra { + constructor(@IExtra public readonly extra: IExtra) {} + } + const INeedsExtra = createDecorator('cascade-needs-extra'); + ix.provide(INeedsExtra, new SyncDescriptor(NeedsExtra)); + expect(ix.cascade.unitState(INeedsExtra)).toBe('Pending'); + + // Sticky: an unrelated transaction does not retry the failed unit. + ix.provide(IRoot, new SyncDescriptor(Root)); + expect(ix.cascade.unitState(IExtra)).toBe('Failed'); + + // Explicit update() recovers it (and wakes its dependent). + shouldThrow = false; + events = []; + return ix.cascade.update(IExtra).then(() => { + expect(ix.cascade.unitState(IExtra)).toBe('Active'); + expect(ix.cascade.unitState(INeedsExtra)).toBe('Active'); + expect(events).toEqual(['+flaky']); + ix.dispose(); + }); + }); + + it('8. async disposers tear down serially in reverse topo order', async () => { + const ix = makeContainer(); + const gates = { root: deferred(), mid: deferred(), leaf: deferred() }; + const makeAsync = (label: string, gate: Promise) => + class { + dispose(): void { + events.push(`${label}-start`); + return gate.then(() => { + events.push(`${label}-end`); + }) as unknown as void; + } + }; + class AsyncRoot extends makeAsync('root', gates.root.promise) implements IRoot { + label = 'root'; + } + class AsyncMid extends makeAsync('mid', gates.mid.promise) implements IMid { + constructor(@IRoot public readonly root: IRoot) { + super(); + } + } + class AsyncLeaf extends makeAsync('leaf', gates.leaf.promise) implements ILeaf { + constructor(@IMid public readonly mid: IMid) { + super(); + } + } + ix.provide(IRoot, new SyncDescriptor(AsyncRoot)); + ix.provide(IMid, new SyncDescriptor(AsyncMid)); + ix.provide(ILeaf, new SyncDescriptor(AsyncLeaf)); + events = []; + + const done = ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'async teardown' }); + // Reverse topo: leaf starts first; mid must not start until leaf finishes. + expect(events).toEqual(['leaf-start']); + gates.root.resolve(); + await flushMicrotasks(); + expect(events).toEqual(['leaf-start']); + gates.leaf.resolve(); + await flushMicrotasks(); + expect(events).toEqual(['leaf-start', 'leaf-end', 'mid-start']); + gates.mid.resolve(); + await done; + expect(events).toEqual(['leaf-start', 'leaf-end', 'mid-start', 'mid-end', 'root-start', 'root-end']); + ix.dispose(); + }); + + it('9. the abort hook cancels in-flight work (bounded wait), then forces through on timeout', async () => { + const ix = makeContainer(); + provideChain(ix); + const seen: { affected: string[]; reason: string }[] = []; + let gate: { promise: Promise; resolve: () => void } | undefined; + ix.cascade.configure({ + abortWaitMs: 30, + onWillCascade: (affected, reason) => { + seen.push({ affected: affected.map((ref) => ref.token.toString()), reason }); + gate = deferred(); + return gate.promise; + }, + }); + + // (a) the cascade waits for the abort to complete. + const first = ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'feature "x" unloaded' }); + expect(seen).toHaveLength(1); + expect(seen[0]!.reason).toBe('feature "x" unloaded'); + expect(seen[0]!.affected).toContain('cascade-leaf'); + await Promise.resolve(); + expect(ix.cascade.isInFlight(IRoot)).toBe(true); // still waiting + gate!.resolve(); + await first; + expect(ix.cascade.history().at(-1)!.abortWaited).toBe(true); + expect(ix.cascade.history().at(-1)!.abortTimedOut).toBe(false); + expect(ix.cascade.unitState(IRoot)).toBeUndefined(); + + // (b) an abort that never completes is forced after the bounded wait. + provideChain(ix); + const second = ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'forced' }); + await second; // never resolved the gate — the bound fired + const entry = ix.cascade.history().at(-1)!; + expect(entry.abortWaited).toBe(true); + expect(entry.abortTimedOut).toBe(true); + expect(ix.cascade.unitState(IRoot)).toBeUndefined(); + ix.dispose(); + }); + + it('10. a resolution hitting the in-flight subgraph suspends and completes after the transaction', async () => { + const ix = makeContainer(); + provideChain(ix); + const gate = deferred(); + ix.cascade.configure({ onWillCascade: () => gate.promise, resolveTimeoutMs: 50 }); + + const replace = ix.cascade.submit({ + action: 'provide', + token: IRoot, + descriptor: new SyncDescriptor(Root), + reason: 'replace root', + }); + expect(ix.cascade.isInFlight(IRoot)).toBe(true); + + // The sync path cannot suspend: it fails fast. + expect(() => ix.invokeFunction((a) => a.get(IRoot))).toThrow(CascadeConflictError); + + // The async path suspends until the transaction completes. + const suspended = ix.cascade.resolveWhenAvailable(IRoot); + gate.resolve(); + await replace; + const root = await suspended; + expect(root).toBeInstanceOf(Root); + + // Timeout variant: a transaction that parks forever rejects suspended resolutions. + const parked = deferred(); + ix.cascade.configure({ onWillCascade: () => parked.promise }); + void ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'parked' }); + await expect(ix.cascade.resolveWhenAvailable(IRoot)).rejects.toThrow(CascadeConflictError); + parked.resolve(); + await ix.cascade.whenIdle(); + ix.dispose(); + }); + + it('11. cycle detection holds under dynamic edge add/remove', () => { + const ix = makeContainer(); + // Cyclic recipes provided dynamically: neither can satisfy its dependency, + // so both wait — no construction, no spurious failure. + const IA = createDecorator<{ a: true }>('cascade-cyc-a'); + const IB = createDecorator<{ b: true }>('cascade-cyc-b'); + class A { + constructor(@IB public readonly b: unknown) {} + } + class B { + constructor(@IA public readonly a: unknown) {} + } + ix.provide(IA, new SyncDescriptor(A)); + ix.provide(IB, new SyncDescriptor(B)); + expect(ix.cascade.unitState(IA)).toBe('Pending'); + expect(ix.cascade.unitState(IB)).toBe('Pending'); + expect(ix.dependencyGraph.findCycle((ref) => ref.token.toString())).toBeNull(); + + // Break the cycle: replace A with an independent recipe; both activate. + class A2 { + readonly a = true; + } + ix.provide(IA, new SyncDescriptor(A2)); + expect(ix.cascade.unitState(IA)).toBe('Active'); + expect(ix.cascade.unitState(IB)).toBe('Active'); + expect(ix.dependencyGraph.findCycle((ref) => ref.token.toString())).toBeNull(); + + // Dynamic chain teardown keeps the graph acyclic and edge-free. + ix.unprovide(IA); + expect(ix.cascade.unitState(IB)).toBe('Pending'); + expect(ix.dependencyGraph.edges()).toHaveLength(0); + expect(ix.dependencyGraph.findCycle((ref) => ref.token.toString())).toBeNull(); + ix.dispose(); + }); + + it('12. ledger balance: arbitrary sequences leave no leaks or dangling edges', async () => { const ix = makeContainer(); + provideChain(ix); + // 3 live instances + 3 provide entries on the book. + expect(ledgerOf(ix).size).toBe(6); + + ix.unprovide(IMid); + // Left on the book: the root instance entry + root/leaf provide entries. + expect(ledgerOf(ix).size).toBe(3); + // Leaf is Pending (waiting on mid); mid is removed; root is Active. + expect(ix.cascade.unitState(ILeaf)).toBe('Pending'); + expect(ix.cascade.unitState(IMid)).toBeUndefined(); + + ix.provide(IMid, new SyncDescriptor(Mid)); + expect(ix.cascade.unitState(ILeaf)).toBe('Active'); + expect(ledgerOf(ix).size).toBe(6); + + await ix.cascade.update(IRoot); + expect(ledgerOf(ix).size).toBe(6); + expect(ix.dependencyGraph.edges()).toHaveLength(2); + + ix.unprovide(ILeaf); + ix.unprovide(IMid); + ix.unprovide(IRoot); + expect(ledgerOf(ix).size).toBe(0); + expect(ix.dependencyGraph.edges()).toHaveLength(0); + expect(ix.dependencyGraph.findCycle((ref) => ref.token.toString())).toBeNull(); + expect(ix.cascade.pendingSnapshot().size).toBe(0); + + ix.dispose(); + expect(ledgerOf(ix).size).toBe(0); + }); + + it('13. replacing with a concrete instance cascades into live dependents (D1)', () => { + const ix = makeContainer(); + provideChain(ix); + const firstMid = ix.invokeFunction((a) => a.get(IMid)); + const replacement = new Root('root2'); + events = []; + + ix.provide(IRoot, replacement); + + // Same transaction: dependents were torn down and rebuilt against the instance. + expect(events).toEqual(['-leaf', '-mid', '-root', '+mid', '+leaf']); + const newMid = ix.invokeFunction((a) => a.get(IMid)); + expect(newMid).not.toBe(firstMid); + expect(newMid.root).toBe(replacement); + expect(ix.invokeFunction((a) => a.get(IRoot))).toBe(replacement); + ix.dispose(); + }); + + it('14. a rejecting abort hook is logged, never a veto (best-effort §4.5)', async () => { + const reported: unknown[] = []; + const { setUnexpectedErrorHandler, resetUnexpectedErrorHandler } = await import( + '#/_base/errors/unexpectedError' + ); + setUnexpectedErrorHandler((err) => { reported.push(err); }); + try { + const ix = makeContainer(); + provideChain(ix); + ix.cascade.configure({ + onWillCascade: () => Promise.reject(new Error('abort hook blew up')), + }); + + await ix.cascade.submit({ action: 'unprovide', token: IRoot, reason: 'forced anyway' }); + + expect(ix.cascade.unitState(IRoot)).toBeUndefined(); + expect(() => ix.invokeFunction((a) => a.get(IRoot))).toThrow(/unknown service/); + expect(ix.cascade.history().at(-1)!.abortWaited).toBe(true); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toContain('abort hook blew up'); + ix.dispose(); + } finally { + resetUnexpectedErrorHandler(); + } + }); +}); + +describe('cascade engine — cross-scope orchestration (D9)', () => { + it('a parent change cascades into child-scope dependents and rebuilds them', () => { + const parent = makeContainer(); + parent.provide(IRoot, new SyncDescriptor(Root)); + const child = parent.createChild(new ServiceCollection()); + child.provide(IMid, new SyncDescriptor(Mid)); + events = []; + + parent.unprovide(IRoot); + // Global reverse topo: the child unit dies before its parent dependency. + expect(events).toEqual(['-mid', '-root']); + expect(child.cascade.unitState(IMid)).toBe('Pending'); + expect(parent.cascade.unitState(IRoot)).toBeUndefined(); + + parent.provide(IRoot, new SyncDescriptor(Root)); + // Global topo rebuild: parent first, then the child dependent. + expect(events).toEqual(['-mid', '-root', '+root', '+mid']); + const mid = child.invokeFunction((a) => a.get(IMid)); + expect(mid.root).toBe(parent.invokeFunction((a) => a.get(IRoot))); + parent.dispose(); + }); + + it('orders a three-level chain globally: deepest first for teardown, reverse for rebuild', () => { + const parent = makeContainer(); + parent.provide(IRoot, new SyncDescriptor(Root)); + const child = parent.createChild(new ServiceCollection()); + child.provide(IMid, new SyncDescriptor(Mid)); + const grandchild = child.createChild(new ServiceCollection()); + grandchild.provide(ILeaf, new SyncDescriptor(Leaf)); + events = []; + + parent.unprovide(IRoot); + expect(events).toEqual(['-leaf', '-mid', '-root']); + + parent.provide(IRoot, new SyncDescriptor(Root)); + expect(events).toEqual(['-leaf', '-mid', '-root', '+root', '+mid', '+leaf']); + parent.dispose(); + }); + + it('shadowing: a child shadow of the changed token is not in the contagion set', () => { + const parent = makeContainer(); + parent.provide(IRoot, new SyncDescriptor(Root)); + const child = parent.createChild(new ServiceCollection()); + // The child registers its own IRoot; the child's Mid binds the shadow. + child.provide(IRoot, new SyncDescriptor(Root, ['shadow'])); + child.provide(IMid, new SyncDescriptor(Mid)); + events = []; + + parent.unprovide(IRoot); + // Only the parent's own root is retired; the child's shadow and its + // dependent are untouched. + expect(events).toEqual(['-root']); + expect(child.cascade.unitState(IMid)).toBe('Active'); + expect(child.cascade.unitState(IRoot)).toBe('Active'); + const mid = child.invokeFunction((a) => a.get(IMid)); + expect(mid.root).toBe(child.invokeFunction((a) => a.get(IRoot))); + parent.dispose(); + }); + + it('siblings are isolated: one child scope\'s change never touches the other', () => { + const parent = makeContainer(); + parent.provide(IRoot, new SyncDescriptor(Root)); + const childA = parent.createChild(new ServiceCollection()); + const childB = parent.createChild(new ServiceCollection()); + childA.provide(IMid, new SyncDescriptor(Mid)); + childB.provide(IMid, new SyncDescriptor(Mid)); + events = []; + + childA.unprovide(IMid); + expect(events).toEqual(['-mid']); + expect(childB.cascade.unitState(IMid)).toBe('Active'); + + // But a parent change reaches both subtrees. + parent.unprovide(IRoot); + expect(events).toEqual(['-mid', '-mid', '-root']); + expect(childB.cascade.unitState(IMid)).toBe('Pending'); + parent.dispose(); + }); + + it('a descendant scope dying mid-transaction is skipped idempotently', () => { + const parent = makeContainer(); + parent.provide(IRoot, new SyncDescriptor(Root)); + const child = parent.createChild(new ServiceCollection()); + child.provide(IMid, new SyncDescriptor(Mid)); + events = []; + parent.cascade.configure({ + onWillCascade: () => { + child.dispose(); + }, + }); + + parent.unprovide(IRoot); + + // The child's own dispose already retired mid; the cascade skips the dead + // scope's units and completes its own teardown. + expect(events).toEqual(['-mid', '-root']); + const entry = parent.cascade.history().at(-1)!; + expect(entry.tornDown).toEqual(['cascade-root']); + expect(parent.cascade.unitState(IRoot)).toBeUndefined(); + parent.dispose(); + }); + + it('the in-flight guard and suspension work across scopes', async () => { + const parent = makeContainer(); + parent.provide(IRoot, new SyncDescriptor(Root)); + const child = parent.createChild(new ServiceCollection()); + child.provide(IMid, new SyncDescriptor(Mid)); + const gate = deferred(); + parent.cascade.configure({ onWillCascade: () => gate.promise }); + + const tx = parent.cascade.submit({ + action: 'provide', + token: IRoot, + descriptor: new SyncDescriptor(Root), + reason: 'replace root', + }); + // The child sees its ancestor's token as in flight. + expect(child.cascade.isInFlight(IRoot)).toBe(true); + expect(() => child.invokeFunction((a) => a.get(IRoot))).toThrow(CascadeConflictError); + + const suspended = child.cascade.resolveWhenAvailable(IRoot); + gate.resolve(); + await tx; + const root = await suspended; + expect(root).toBeInstanceOf(Root); + expect(child.cascade.unitState(IMid)).toBe('Active'); + parent.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/_base/di/child.test.ts b/packages/agent-core-v2/test/_base/di/child.test.ts index 99d3da62e7b..eebce1e32c8 100644 --- a/packages/agent-core-v2/test/_base/di/child.test.ts +++ b/packages/agent-core-v2/test/_base/di/child.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, afterEach } from 'vitest'; +import { + resetUnexpectedErrorHandler, + setUnexpectedErrorHandler, +} from '#/_base/errors/unexpectedError'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { IInstantiationService, @@ -351,7 +355,7 @@ describe('InstantiationService.createChild', () => { }); describe('Disposable base class', () => { - it('insertion order on dispose', () => { + it('reverse registration order on dispose (ledger teardown)', () => { const events: string[] = []; class Child implements IDisposable { constructor(public readonly label: string) {} @@ -369,7 +373,7 @@ describe('Disposable base class', () => { } const o = new Owner(); o.dispose(); - expect(events).toEqual(['disposed first', 'disposed second', 'disposed third']); + expect(events).toEqual(['disposed third', 'disposed second', 'disposed first']); }); it('idempotent dispose on the base class', () => { @@ -409,8 +413,15 @@ describe('Disposable base class', () => { expect(events).toEqual(['disposed']); }); - it('continues teardown and rethrows if one child throws', () => { + it('continues teardown and reports if one child throws (rollback is uninterruptible)', () => { const events: string[] = []; + const reported: unknown[] = []; + setUnexpectedErrorHandler((err) => { + reported.push(err); + }); + afterEach(() => { + resetUnexpectedErrorHandler(); + }); class GoodChild implements IDisposable { dispose(): void { events.push('good'); @@ -436,7 +447,11 @@ describe('Disposable base class', () => { } } const o = new Owner(); - expect(() => { o.dispose(); }).toThrow('boom'); - expect(events).toEqual(['good', 'bad-attempted', 'tail']); + // Ledger semantics: teardown is uninterruptible — a failing entry is + // reported via onUnexpectedError and teardown continues (reverse order). + expect(() => { o.dispose(); }).not.toThrow(); + expect(events).toEqual(['tail', 'bad-attempted', 'good']); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toContain('boom'); }); }); diff --git a/packages/agent-core-v2/test/_base/di/provide.test.ts b/packages/agent-core-v2/test/_base/di/provide.test.ts new file mode 100644 index 00000000000..83c57efa804 --- /dev/null +++ b/packages/agent-core-v2/test/_base/di/provide.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { createDecorator } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import type { AvailabilityChange } from '#/_base/di/serviceCollection'; + +interface IFoo { + tag: string; +} +const IFoo = createDecorator('provide-foo'); + +interface IBar { + tag: string; +} +const IBar = createDecorator('provide-bar'); + +class Foo implements IFoo, IDisposable { + tag = 'foo'; + disposed = false; + dispose(): void { + this.disposed = true; + } +} + +class Bar implements IBar { + tag = 'bar'; + constructor(@IFoo public readonly foo: IFoo) {} +} + +describe('InstantiationService.provide/unprovide (L1)', () => { + it('provides a service at runtime and resolves it', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + const foo = ix.invokeFunction((a) => a.get(IFoo)); + expect(foo).toBeInstanceOf(Foo); + ix.dispose(); + }); + + it('unprovide removes the token; strict resolution then throws', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.invokeFunction((a) => a.get(IFoo)); + ix.unprovide(IFoo); + expect(() => ix.invokeFunction((a) => a.get(IFoo))).toThrow(/unknown service/); + ix.dispose(); + }); + + it('unprovide retires the materialized instance', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + const foo = ix.invokeFunction((a) => a.get(IFoo)) as Foo; + expect(foo.disposed).toBe(false); + ix.unprovide(IFoo); + expect(foo.disposed).toBe(true); + ix.dispose(); + }); + + it('reprovide retires the old generation and resolves a fresh instance', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + const first = ix.invokeFunction((a) => a.get(IFoo)) as Foo; + + ix.provide(IFoo, new SyncDescriptor(Foo)); + expect(first.disposed).toBe(true); + + const second = ix.invokeFunction((a) => a.get(IFoo)) as Foo; + expect(second).not.toBe(first); + expect(second.disposed).toBe(false); + ix.dispose(); + expect(second.disposed).toBe(true); + }); + + it('stamps every generation with a container-monotonic uid', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + const h1 = ix.provide(IFoo, new SyncDescriptor(Foo)); + const h2 = ix.provide(IBar, new SyncDescriptor(Bar)); + ix.provide(IFoo, new SyncDescriptor(Foo)); + const uidAfter = (ix as unknown as { _services: ServiceCollection })._services.uidOf(IFoo)!; + expect(h2.uid).toBeGreaterThan(h1.uid); + expect(uidAfter).toBeGreaterThan(h2.uid); + ix.dispose(); + }); + + it('fires availability events with { oldUid, newUid } on provide/unprovide', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + const changes: AvailabilityChange[] = []; + const services = (ix as unknown as { _services: ServiceCollection })._services; + services.onDidChange(IFoo, (change) => changes.push(change)); + + const h1 = ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IFoo, new SyncDescriptor(Foo)); + const uid2 = services.uidOf(IFoo)!; + ix.unprovide(IFoo); + + expect(changes).toEqual([ + { oldUid: undefined, newUid: h1.uid }, + { oldUid: h1.uid, newUid: uid2 }, + { oldUid: uid2, newUid: undefined }, + ]); + ix.dispose(); + }); + + it('records the pinned flag on the entry', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo), { pinned: true }); + const services = (ix as unknown as { _services: ServiceCollection })._services; + expect(services.isPinned(IFoo)).toBe(true); + // Re-provide without the flag keeps the previous pinned metadata. + ix.provide(IFoo, new SyncDescriptor(Foo)); + expect(services.isPinned(IFoo)).toBe(true); + ix.dispose(); + }); + + it('the provide handle is a ledger entry: disposing it unprovides', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + const handle = ix.provide(IFoo, new SyncDescriptor(Foo)); + handle.dispose(); + expect(() => ix.invokeFunction((a) => a.get(IFoo))).toThrow(/unknown service/); + ix.dispose(); + }); + + it('container teardown retires provided services exactly once', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + const foo = ix.invokeFunction((a) => a.get(IFoo)) as Foo; + let calls = 0; + const origDispose = foo.dispose.bind(foo); + foo.dispose = () => { + calls += 1; + origDispose(); + }; + ix.dispose(); + expect(calls).toBe(1); + }); +}); + +describe('persistent dependency graph (L2 substrate)', () => { + it('records constructor-injection edges for materialized services', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IBar, new SyncDescriptor(Bar)); + ix.invokeFunction((a) => a.get(IBar)); + + const edges = ix.dependencyGraph.edges(); + expect(edges).toHaveLength(1); + expect(edges[0]).toMatchObject({ + consumer: { scope: ix, token: IBar }, + dependency: { scope: ix, token: IFoo }, + kind: 'instance', + }); + ix.dispose(); + }); + + it('affectedSet computes the transitive dependents of a changed token', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IBar, new SyncDescriptor(Bar)); + ix.invokeFunction((a) => a.get(IBar)); + + const tokens = (refs: readonly { token: unknown }[]): unknown[] => + refs.map((ref) => ref.token); + expect(tokens(ix.dependencyGraph.affectedSet([{ scope: ix, token: IFoo }]))).toEqual([IFoo, IBar]); + expect(tokens(ix.dependencyGraph.affectedSet([{ scope: ix, token: IBar }]))).toEqual([IBar]); + ix.dispose(); + }); + + it('orders the affected set: dependents first for teardown, dependencies first for rebuild', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IBar, new SyncDescriptor(Bar)); + ix.invokeFunction((a) => a.get(IBar)); + + const affected = ix.dependencyGraph.affectedSet([{ scope: ix, token: IFoo }]); + const tokens = (refs: readonly { token: unknown }[]): unknown[] => + refs.map((ref) => ref.token); + expect(tokens(ix.dependencyGraph.reverseTopoOrder(affected))).toEqual([IBar, IFoo]); + expect(tokens(ix.dependencyGraph.topoOrder(affected))).toEqual([IFoo, IBar]); + ix.dispose(); + }); + + it('retiring a consumer removes its edges', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IBar, new SyncDescriptor(Bar)); + ix.invokeFunction((a) => a.get(IBar)); + + ix.unprovide(IBar); + expect(ix.dependencyGraph.edges()).toHaveLength(0); + const remaining = ix.dependencyGraph.affectedSet([{ scope: ix, token: IFoo }]); + expect(remaining.map((ref) => ref.token)).toEqual([IFoo]); + ix.dispose(); + }); + + it('container teardown leaves the graph empty (no dangling edges)', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + ix.provide(IBar, new SyncDescriptor(Bar)); + ix.invokeFunction((a) => a.get(IBar)); + ix.dispose(); + expect(ix.dependencyGraph.edges()).toHaveLength(0); + }); + + it('does not track createInstance products (leaves)', () => { + const ix = new InstantiationService(new ServiceCollection(), true); + ix.provide(IFoo, new SyncDescriptor(Foo)); + class Leaf { + constructor(@IFoo public readonly foo: IFoo) {} + } + ix.createInstance(Leaf); + expect(ix.dependencyGraph.edges()).toHaveLength(0); + ix.dispose(); + }); +}); + +describe('TestInstantiationService.set rerouting', () => { + it('set() on a materialized token retires the previous generation', async () => { + const { TestInstantiationService } = await import('#/_base/di/testInstantiationService'); + const ix = new TestInstantiationService(new ServiceCollection(), true); + ix.set(IFoo, new SyncDescriptor(Foo)); + const first = ix.get(IFoo) as Foo; + ix.set(IFoo, new SyncDescriptor(Foo)); + expect(first.disposed).toBe(true); + const second = ix.get(IFoo) as Foo; + expect(second).not.toBe(first); + ix.dispose(); + }); + + it('set() returns the previous value like before', async () => { + const { TestInstantiationService } = await import('#/_base/di/testInstantiationService'); + const ix = new TestInstantiationService(new ServiceCollection(), true); + const seeded = new Foo(); + expect(ix.set(IFoo, seeded)).toBeUndefined(); + expect(ix.set(IFoo, new Foo())).toBe(seeded); + ix.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/_base/lifecycle/ledger.test.ts b/packages/agent-core-v2/test/_base/lifecycle/ledger.test.ts new file mode 100644 index 00000000000..5ea721df1a8 --- /dev/null +++ b/packages/agent-core-v2/test/_base/lifecycle/ledger.test.ts @@ -0,0 +1,427 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + resetUnexpectedErrorHandler, + setUnexpectedErrorHandler, +} from '#/_base/errors/unexpectedError'; +import { LedgerDisposedError } from '#/_base/lifecycle/errors'; +import { Ledger } from '#/_base/lifecycle/ledger'; +import type { Disposer, TeardownReason } from '#/_base/lifecycle/disposer'; + +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('Ledger', () => { + describe('teardown ordering', () => { + it('tears down entries in strict reverse registration order', () => { + const events: string[] = []; + const ledger = new Ledger('test'); + ledger.register(() => { events.push('first'); }, 'first'); + ledger.register(() => { events.push('second'); }, 'second'); + ledger.register(() => { events.push('third'); }, 'third'); + void ledger.teardown(); + expect(events).toEqual(['third', 'second', 'first']); + expect(ledger.state).toBe('disposed'); + }); + + it('completes synchronously when every entry is synchronous', () => { + const ledger = new Ledger('test'); + ledger.register(() => {}, 'a'); + ledger.register(() => {}, 'b'); + const out = ledger.teardown(); + expect(out).toBeUndefined(); + expect(ledger.state).toBe('disposed'); + expect(ledger.isDisposed).toBe(true); + }); + + it('mixes sync and async entries: async entries suspend teardown, order holds', async () => { + const events: string[] = []; + const gate = deferred(); + const ledger = new Ledger('test'); + ledger.register(() => { events.push('sync-1'); }, 'sync-1'); + ledger.register(async () => { + events.push('async-start'); + await gate.promise; + events.push('async-end'); + }, 'async'); + ledger.register(() => { events.push('sync-3'); }, 'sync-3'); + + const out = ledger.teardown(); + expect(out).toBeInstanceOf(Promise); + expect(ledger.state).toBe('disposing'); + // Reverse order: sync-3 runs first (same tick), then the async entry starts. + expect(events).toEqual(['sync-3', 'async-start']); + + gate.resolve(); + await out; + expect(events).toEqual(['sync-3', 'async-start', 'async-end', 'sync-1']); + expect(ledger.state).toBe('disposed'); + }); + + it('awaits each entry serially: the next disposer starts only after the previous resolves', async () => { + const events: string[] = []; + const gates = [deferred(), deferred()]; + const ledger = new Ledger('test'); + ledger.register(async () => { + events.push('a-start'); + await gates[0]!.promise; + events.push('a-end'); + }, 'a'); + ledger.register(async () => { + events.push('b-start'); + await gates[1]!.promise; + events.push('b-end'); + }, 'b'); + + const out = ledger.teardown(); + expect(events).toEqual(['b-start']); + // Resolving the later-registered gate must not unblock the earlier one. + gates[0]!.resolve(); + await Promise.resolve(); + expect(events).toEqual(['b-start']); + gates[1]!.resolve(); + await out; + expect(events).toEqual(['b-start', 'b-end', 'a-start', 'a-end']); + }); + }); + + describe('registration guards', () => { + it('throws when registering into a disposed ledger', () => { + const ledger = new Ledger('test'); + void ledger.teardown(); + expect(() => ledger.register(() => {}, 'late')).toThrow(LedgerDisposedError); + expect(() => ledger.effect(() => () => {}, 'late')).toThrow(LedgerDisposedError); + expect(() => ledger.createChild('late')).toThrow(LedgerDisposedError); + }); + + it('throws when registering while teardown is in flight', async () => { + const gate = deferred(); + const ledger = new Ledger('test'); + let caught: unknown; + ledger.register(async () => { + await gate.promise; + // Registration happens while the ledger is 'disposing': it must throw + // at the call site (the entry itself is guarded, so the error does not + // abort the in-flight teardown). + try { + ledger.register(() => {}, 'late'); + } catch (error) { + caught = error; + } + }, 'slow'); + + const out = ledger.teardown(); + gate.resolve(); + await out; + expect(caught).toBeInstanceOf(LedgerDisposedError); + expect(ledger.state).toBe('disposed'); + }); + }); + + describe('uninterruptible rollback', () => { + const reported: unknown[] = []; + + afterEach(() => { + reported.length = 0; + resetUnexpectedErrorHandler(); + }); + + it('a throwing entry is reported (with label) and teardown continues', () => { + setUnexpectedErrorHandler((err) => { reported.push(err); }); + const events: string[] = []; + const ledger = new Ledger('test'); + ledger.register(() => { events.push('first'); }, 'first'); + ledger.register(() => { + events.push('bad-attempted'); + throw new Error('boom'); + }, 'bad'); + ledger.register(() => { events.push('third'); }, 'third'); + + expect(() => ledger.teardown()).not.toThrow(); + expect(events).toEqual(['third', 'bad-attempted', 'first']); + expect(ledger.state).toBe('disposed'); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toContain('boom'); + expect((reported[0] as Error).message).toContain('bad'); + }); + + it('a rejecting async entry is reported and teardown continues', async () => { + setUnexpectedErrorHandler((err) => { reported.push(err); }); + const events: string[] = []; + const ledger = new Ledger('test'); + ledger.register(() => { events.push('first'); }, 'first'); + ledger.register(async () => { + events.push('bad-attempted'); + throw new Error('async boom'); + }, 'bad'); + ledger.register(() => { events.push('third'); }, 'third'); + + await ledger.teardown(); + expect(events).toEqual(['third', 'bad-attempted', 'first']); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toContain('async boom'); + }); + }); + + describe('construction failure auto-rollback', () => { + it('sync iterator: a mid-iteration throw rolls back already-yielded disposers in reverse', () => { + const events: string[] = []; + const ledger = new Ledger('test'); + expect(() => + ledger.effect(function* () { + yield () => { events.push('undo-1'); }; + yield () => { events.push('undo-2'); }; + throw new Error('construct failed'); + }, 'gen'), + ).toThrow('construct failed'); + expect(events).toEqual(['undo-2', 'undo-1']); + expect(ledger.size).toBe(0); + }); + + it('async iterator: a mid-iteration throw rolls back already-yielded disposers in reverse', async () => { + const events: string[] = []; + const ledger = new Ledger('test'); + // eslint-disable-next-line require-yield + const body = async function* (): AsyncGenerator { + yield () => { events.push('undo-1'); }; + yield () => { events.push('undo-2'); }; + throw new Error('async construct failed'); + }; + const entry = ledger.effect(body, 'gen'); + // The failure surfaces through the entry's disposer chain; teardown reports it. + const reported: unknown[] = []; + setUnexpectedErrorHandler((err) => { reported.push(err); }); + try { + await ledger.teardown(); + expect(events).toEqual(['undo-2', 'undo-1']); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toContain('async construct failed'); + } finally { + resetUnexpectedErrorHandler(); + } + expect(entry.disposed).toBe(true); + }); + }); + + describe('effect return forms', () => { + it('void body: entry exists for introspection, nothing to roll back', () => { + const ledger = new Ledger('test'); + ledger.effect(() => {}, 'noop'); + expect(ledger.size).toBe(1); + expect(ledger.entries()[0]).toMatchObject({ label: 'noop', kind: 'effect' }); + void ledger.teardown(); + expect(ledger.state).toBe('disposed'); + }); + + it('plain disposer', () => { + const events: string[] = []; + const ledger = new Ledger('test'); + ledger.effect(() => () => { events.push('disposed'); }, 'fx'); + void ledger.teardown(); + expect(events).toEqual(['disposed']); + }); + + it('Promise: teardown awaits the promise then runs the disposer', async () => { + const events: string[] = []; + const gate = deferred(); + const ledger = new Ledger('test'); + ledger.effect(() => gate.promise, 'async-fx'); + ledger.register(() => { events.push('first'); }, 'first'); + + const out = ledger.teardown(); + expect(events).toEqual(['first']); + gate.resolve(() => { events.push('async-disposed'); }); + await out; + expect(events).toEqual(['first', 'async-disposed']); + }); + + it('sync iterator: yields are rolled back in reverse at teardown', () => { + const events: string[] = []; + const ledger = new Ledger('test'); + ledger.effect(function* () { + events.push('setup-1'); + yield () => { events.push('undo-1'); }; + events.push('setup-2'); + yield () => { events.push('undo-2'); }; + }, 'gen'); + expect(events).toEqual(['setup-1', 'setup-2']); + void ledger.teardown(); + expect(events).toEqual(['setup-1', 'setup-2', 'undo-2', 'undo-1']); + }); + + it('async iterator: yields are rolled back in reverse at teardown', async () => { + const events: string[] = []; + const ledger = new Ledger('test'); + const body = async function* (): AsyncGenerator { + yield () => { events.push('undo-1'); }; + yield () => { events.push('undo-2'); }; + }; + ledger.effect(body, 'gen'); + await ledger.teardown(); + expect(events).toEqual(['undo-2', 'undo-1']); + }); + }); + + describe('child ledgers', () => { + it('a child ledger is one entry of the parent and tears down with it', () => { + const events: string[] = []; + const parent = new Ledger('parent'); + parent.register(() => { events.push('parent-entry'); }, 'parent-entry'); + const child = parent.createChild('child'); + child.register(() => { events.push('child-entry'); }, 'child-entry'); + + void parent.teardown(); + expect(events).toEqual(['child-entry', 'parent-entry']); + expect(child.state).toBe('disposed'); + expect(parent.state).toBe('disposed'); + }); + + it('a child torn down directly detaches from the parent', () => { + const events: string[] = []; + const parent = new Ledger('parent'); + const child = parent.createChild('child'); + child.register(() => { events.push('child-entry'); }, 'child-entry'); + + void child.teardown(); + expect(events).toEqual(['child-entry']); + expect(parent.entries()).toHaveLength(0); + + void parent.teardown(); + expect(events).toEqual(['child-entry']); + }); + }); + + describe('introspection', () => { + it('entries() renders the book as a tree', () => { + const parent = new Ledger('parent'); + parent.register(() => {}, 'a'); + parent.effect(() => () => {}, 'fx'); + const child = parent.createChild('child-scope'); + child.register(() => {}, 'child-a'); + + expect(parent.entries()).toEqual([ + { label: 'a', kind: 'disposer', stack: undefined, children: undefined }, + { label: 'fx', kind: 'effect', stack: undefined, children: undefined }, + { + label: 'child-scope', + kind: 'ledger', + stack: undefined, + children: [ + { label: 'child-a', kind: 'disposer', stack: undefined, children: undefined }, + ], + }, + ]); + }); + + it('captures the registration stack when enabled', () => { + Ledger.captureStacks = true; + try { + const ledger = new Ledger('test'); + ledger.register(() => {}, 'traced'); + expect(ledger.entries()[0]!.stack).toContain('Ledger registration'); + } finally { + Ledger.captureStacks = false; + } + }); + }); + + describe('idempotency', () => { + it('teardown is idempotent: disposers run exactly once', async () => { + const spy = vi.fn(); + const ledger = new Ledger('test'); + ledger.register(spy, 'spy'); + void ledger.teardown(); + void ledger.teardown(); + await ledger.teardown(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('a concurrent teardown joins the in-flight one', async () => { + const gate = deferred(); + const spy = vi.fn(async () => { await gate.promise; }); + const ledger = new Ledger('test'); + ledger.register(spy, 'spy'); + const first = ledger.teardown(); + const second = ledger.teardown(); + expect(second).toBe(first); + gate.resolve(); + await first; + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('entry.dispose is idempotent and removes the entry from the book', () => { + const spy = vi.fn(); + const ledger = new Ledger('test'); + const entry = ledger.register(spy, 'spy'); + void entry.dispose(); + void entry.dispose(); + expect(spy).toHaveBeenCalledTimes(1); + expect(entry.disposed).toBe(true); + expect(ledger.size).toBe(0); + }); + + it('entry.release removes the entry without running its disposer', () => { + const spy = vi.fn(); + const ledger = new Ledger('test'); + const entry = ledger.register(spy, 'spy'); + entry.release(); + entry.release(); + expect(entry.disposed).toBe(true); + void ledger.teardown(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('reason propagation', () => { + it.each(['scope-close', 'cascade', 'unload'] as TeardownReason[])( + 'teardown(%s) reaches every disposer, including effect forms', + async (reason) => { + const seen: TeardownReason[] = []; + const ledger = new Ledger('test'); + ledger.register((r) => { seen.push(r); }, 'plain'); + ledger.effect(() => (r) => { seen.push(r); }, 'fx'); + ledger.effect(function* (): Generator { + yield (r) => { seen.push(r); }; + }, 'gen'); + const child = ledger.createChild('child'); + child.register((r) => { seen.push(r); }, 'child-plain'); + + await ledger.teardown(reason); + expect(seen).toEqual([reason, reason, reason, reason]); + }, + ); + + it('entry.dispose(reason) propagates the reason', () => { + const seen: TeardownReason[] = []; + const ledger = new Ledger('test'); + const entry = ledger.register((r) => { seen.push(r); }, 'plain'); + void entry.dispose('cascade'); + expect(seen).toEqual(['cascade']); + }); + }); + + describe('clear', () => { + it('tears down current entries but keeps the ledger active', () => { + const events: string[] = []; + const ledger = new Ledger('test'); + ledger.register(() => { events.push('a'); }, 'a'); + void ledger.clear(); + expect(events).toEqual(['a']); + expect(ledger.state).toBe('active'); + ledger.register(() => { events.push('b'); }, 'b'); + void ledger.teardown(); + expect(events).toEqual(['a', 'b']); + }); + }); +}); From 4b25b50342fd020434099b1463dfe1a771e3866f Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 3 Aug 2026 15:42:09 +0800 Subject: [PATCH 05/33] docs: clarify secondary model default binding and override precedence (#2553) The secondary_model section did not state whether spawned subagents are forced onto the secondary model or only default to it, nor the full override precedence. Make the semantics explicit in both locales: - spawning resolves the model in order: explicit tool-call model -> profile model_preference -> configured secondary model (default) - the tool's model parameter accepts only "primary" / "secondary" - "primary" means the model the main agent is currently running, not necessarily default_model - the user has no per-spawn switch; overriding is the main agent's decision or a profile setting Also unify secondary-model terminology and the [models] alias wording across the config-files, agents, slash-commands, and env-vars pages. --- docs/en/configuration/config-files.md | 8 ++++++-- docs/en/configuration/env-vars.md | 2 +- docs/en/customization/agents.md | 2 +- docs/en/reference/slash-commands.md | 2 +- docs/zh/configuration/config-files.md | 16 ++++++++++------ docs/zh/configuration/env-vars.md | 2 +- docs/zh/customization/agents.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- 8 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index b671922578e..23633b29396 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -190,7 +190,11 @@ You can also switch models temporarily without touching the config file — by s ## `secondary_model` -The secondary model is a second model pointer next to the primary `default_model` — typically a cheaper model that features can bind to when they do not need the main model. Its consumer today is subagent spawning: when set, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model, and the main agent is told it can pick per spawn between `"secondary"` (this model) and `"primary"` (the main model). When unset, subagents inherit the main agent's model. +The secondary model is a second model configuration alongside the main model — typically a cheaper one, for features that do not need the main model's capability. Its consumer today is subagent spawning: when set, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model; when unset, subagents inherit the main agent's model. + +This is a default binding, not a forced one. With the experiment enabled, the `Agent` / `AgentSwarm` tools gain a `model` parameter (accepting only the symbolic values `"secondary"` / `"primary"`), and the tool description lists the available models with the default marked. A spawn resolves the subagent's model in this order: an explicit tool-call `model` → the profile's [`model_preference`](../customization/agents.md#agent-file-format) → the configured secondary model (the default). Here `"primary"` means the model the main agent is currently running, not necessarily `default_model` — for example after a mid-session `/model` switch. + +Because overriding the default is the main agent's own decision (the tool description merely suggests `"secondary"` for routine tasks and `"primary"` for hard, quality-sensitive ones), there is no per-spawn switch on the user side. To steer a specific subagent to the main model, ask the main agent in your prompt to pass `model: "primary"`, or set `model_preference: "primary"` in the corresponding profile. This feature is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG=1`. It takes effect in every launch mode, including the interactive TUI. @@ -198,7 +202,7 @@ In the interactive TUI, the [`/secondary_model`](../reference/slash-commands.md) | Field | Type | Default | Description | | --- | --- | --- | --- | -| `model` | `string` | — | A model id from your configured `[models]` (any provider, not limited to Kimi models) | +| `model` | `string` | — | The alias of a configured [`[models]`](#models) entry, e.g. `kimi-code/kimi-k2.5` (any provider, not limited to Kimi models) | | `default_effort` | `string` | — | Thinking effort applied when subagents bind to the secondary model. Unset, the effort resolves naturally (global `[thinking]` config → the bound model's default effort) instead of inheriting the main agent's effort. Follows the main model's thinking-effort semantics: models with strict effort validation (e.g. Kimi models) fall back to their default effort for unsupported values; other providers receive the value as-is | | Other fields | — | — | Accepts every field of [`[models."".overrides]`](#models) (`max_context_size`, `max_output_size`, `support_efforts`, …) as a model patch applied only to subagents | diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 9fb803d56c0..50e3c968984 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -129,7 +129,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental secondary-model feature in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | -| `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than `[secondary_model] model` in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model | A model id from your configured `[models]`, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | +| `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than [`[secondary_model] model`](./config-files.md#secondary-model) in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model | The alias of a configured `[models]` entry, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | | `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled | An effort value, e.g. `low`; blank values are ignored | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index b9c67039dde..2b247a3a025 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -100,7 +100,7 @@ You are a strict code reviewer. Read the diff, then report findings grouped by s | `description` | yes | What the agent does. Shown to the main Agent when it picks a sub-agent, so write it to guide delegation decisions | | `whenToUse` | no | Extra hint describing when the agent should be used | | `override` | no | Whether this file may replace a same-name built-in Agent. Defaults to `false`; `--agent-file` is already explicit and does not require this field | -| `model_preference` | no | Symbolic default used when `Agent` or `AgentSwarm` spawns this profile: `primary` selects the caller's main model, while `secondary` selects `[secondary_model] model`. An explicit tool-call `model` wins; without either setting, the configured secondary model remains the default. If no secondary model is configured, the subagent inherits the caller's model | +| `model_preference` | no | Symbolic default used when `Agent` or `AgentSwarm` spawns this profile: `primary` selects the model the caller is currently running, while `secondary` selects [`[secondary_model] model`](../configuration/config-files.md#secondary-model). An explicit tool-call `model` (which likewise accepts only `"primary"` / `"secondary"`) wins over this field; without either setting, the configured secondary model remains the default. If no secondary model is configured, the subagent inherits the caller's model | | `tools` | no | Allowlist of tool names such as `Read` or `Bash`; MCP tools are matched with globs such as `mcp__github__*`. Accepts a YAML list or a comma-separated string (`tools: Read, Grep`). Omit to allow all tools; a lone `*` also allows all tools; an empty list (`tools: []`) disables all tools | | `disallowedTools` | no | Denylist with the same syntax and matching rules, applied after `tools` | | `subagents` | no | Allowlist of sub-agent names this agent may delegate to, with the same syntax as `tools` (YAML list or comma-separated string). Omit to allow every type; a lone `*` also allows all types | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 602853cfdca..20d5bc97e42 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -16,7 +16,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/logout` | — | Clear credentials for the currently selected account | No | | `/provider` | — | Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-—-interactive-provider-management) | Yes | | `/model` | — | Switch the LLM model used in the current session | Yes | -| `/secondary_model` | — | Configure the secondary model used by subagents (writes the [`[secondary_model]`](../configuration/config-files.md#secondary-model) section and applies to the current session immediately). Requires the `secondary-model` experiment | Yes | +| `/secondary_model` | — | Configure the secondary model that newly spawned subagents bind to by default (writes the [`[secondary_model]`](../configuration/config-files.md#secondary-model) section and applies to the current session immediately). Requires the `secondary-model` experiment | Yes | | `/settings` | `/config` | Open the settings panel inside the TUI | Yes | | `/experiments` | `/experimental` | Open the experimental feature panel | Yes | | `/permission` | — | Select a permission mode | Yes | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 478959acf69..aaeea89b255 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -190,19 +190,23 @@ display_name = "Kimi for Coding (custom)" ## `secondary_model` -次主力模型是主模型 `default_model` 之外的第二个模型指针——通常是一个更便宜的模型,供不需要主模型的功能绑定使用。目前的消费者是子 Agent 派生:设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;主 Agent 会被告知每次派生可在 `"secondary"`(该模型)与 `"primary"`(主模型)之间选择。未设置时,子 Agent 继承主 Agent 的模型。 +次主力模型是主模型之外的第二个模型配置——通常是一个更便宜的模型,供不需要主模型能力的功能绑定使用。它目前的消费者是子 Agent 派生:设置后,新派生的子 Agent(`Agent` / `AgentSwarm`)默认绑定该模型,而不再继承主 Agent 的模型;未设置时,子 Agent 继承主 Agent 的模型。 + +这是默认绑定而非强制。实验功能启用后,`Agent` / `AgentSwarm` 工具会获得 `model` 参数(仅接受 `"secondary"` / `"primary"` 两个符号值),工具描述中也会列出可选模型并标注默认值。派生时按以下顺序解析子 Agent 的模型:工具调用显式传入的 `model` → 子 Agent profile 的 [`model_preference`](../customization/agents.md#agent-文件格式) → 已配置的次主力模型(默认)。其中 `"primary"` 指主 Agent 当前正在运行的模型,不一定是 `default_model`——例如会话中途用 `/model` 切换过模型。 + +由于是否覆盖默认值由主 Agent 自行决定(工具描述仅建议常规任务用 `"secondary"`、困难或质量敏感的任务用 `"primary"`,不构成强制),用户没有单次派生级别的直接开关。想让某个子 Agent 使用主模型,可以在提示词中要求主 Agent 传入 `model: "primary"`,或在对应 profile 中设置 `model_preference: "primary"`。 该功能目前是实验功能,默认关闭。通过 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1` 启用,或使用 master `KIMI_CODE_EXPERIMENTAL_FLAG=1`。它在包括交互式 TUI 在内的所有启动方式下生效。 -在交互式 TUI 中,可以使用 [`/secondary_model`](../reference/slash-commands.md) 命令打开模型选择器来设置该配置:选择后会写入本小节配置,并在当前会话立即生效——之后派生的子 Agent 会直接绑定新的第二模型。 +在交互式 TUI 中,可以使用 [`/secondary_model`](../reference/slash-commands.md) 命令打开模型选择器来设置该配置:选择后会写入本小节配置,并在当前会话立即生效——之后派生的子 Agent 会直接绑定新的次主力模型。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `model` | `string` | — | 已配置 `[models]` 中的模型 id(不限 kimi 模型,可用任意供应商) | -| `default_effort` | `string` | — | 子代理绑定次主力模型时使用的 thinking effort。未设置时按"全局 `[thinking]` 配置 → 模型默认 effort"的链路解析,不再继承主 Agent 的 effort。与主模型的 thinking effort 语义一致:严格校验 effort 的模型(如 kimi 模型)在不支持该取值时回退到模型默认 effort,其他供应商的模型按原样发送给后端 | -| 其他字段 | — | — | 接受 [`[models."".overrides]`](#models) 的全部字段(`max_context_size`、`max_output_size`、`support_efforts` 等),作为仅对子代理生效的模型补丁 | +| `model` | `string` | — | [`[models]`](#models) 中已配置条目的别名,如 `kimi-code/kimi-k2.5`(不限 kimi 模型,可用任意供应商) | +| `default_effort` | `string` | — | 子 Agent 绑定次主力模型时使用的 thinking effort。未设置时按"全局 `[thinking]` 配置 → 模型默认 effort"的链路解析,不再继承主 Agent 的 effort。与主模型的 thinking effort 语义一致:严格校验 effort 的模型(如 kimi 模型)在不支持该取值时回退到模型默认 effort,其他供应商的模型按原样发送给后端 | +| 其他字段 | — | — | 接受 [`[models."".overrides]`](#models) 的全部字段(`max_context_size`、`max_output_size`、`support_efforts` 等),作为仅对子 Agent 生效的模型补丁 | -`model` 之外的字段构成补丁:存在补丁字段时,运行时会在内存中合成一个派生模型条目(被指向条目的拷贝,补丁并入其 overrides 且补丁优先),子代理实际绑定该派生条目;没有补丁字段时,子代理直接绑定 `model` 指向的条目。派生条目只存在于内存中(不写回 `config.toml`),也不会出现在模型选择列表里。 +`model` 之外的字段构成补丁:存在补丁字段时,运行时会在内存中合成一个派生模型条目(被指向条目的拷贝,补丁并入其 overrides 且补丁优先),子 Agent 实际绑定该派生条目;没有补丁字段时,子 Agent 直接绑定 `model` 指向的条目。派生条目只存在于内存中(不写回 `config.toml`),也不会出现在模型选择列表里。 ```toml [secondary_model] diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 2b5c461a4b7..40a31d6c31b 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -129,7 +129,7 @@ kimi | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的次主力模型功能;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | -| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 `[secondary_model] model`。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | 已配置 `[models]` 中的模型 id,如 `kimi-code/kimi-k2.5`;空白值被忽略 | +| `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 [`[secondary_model] model`](./config-files.md#secondary-model)。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | `[models]` 中已配置条目的别名,如 `kimi-code/kimi-k2.5`;空白值被忽略 | | `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效 | effort 取值,如 `low`;空白值被忽略 | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 9a935e94fba..97d98de3e22 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -100,7 +100,7 @@ disallowedTools: | `description` | 是 | Agent 的用途。主 Agent 挑选子 Agent 时会看到,请围绕委派决策来写 | | `whenToUse` | 否 | 补充说明何时应使用该 Agent | | `override` | 否 | 是否允许覆盖同名内置 Agent,默认 `false`。`--agent-file` 属于显式启动意图,无需设置此字段 | -| `model_preference` | 否 | `Agent` 或 `AgentSwarm` 启动该 profile 时的符号默认值:`primary` 选择调用方的主模型,`secondary` 选择 `[secondary_model] model`。工具调用显式传入的 `model` 优先;两者均未设置时,已配置的次主力模型仍为默认值。未配置次主力模型时,子 Agent 继承调用方模型 | +| `model_preference` | 否 | `Agent` 或 `AgentSwarm` 启动该 profile 时的符号默认值:`primary` 选择调用方当前运行的模型,`secondary` 选择 [`[secondary_model] model`](../configuration/config-files.md#secondary-model)。工具调用显式传入的 `model`(同样只接受 `"primary"` / `"secondary"` 两个符号值)优先于该字段;两者均未设置时,已配置的次主力模型仍为默认值。未配置次主力模型时,子 Agent 继承调用方模型 | | `tools` | 否 | 工具名允许列表,如 `Read`、`Bash`;MCP 工具用 glob 匹配,如 `mcp__github__*`。支持 YAML 列表或逗号分隔字符串(`tools: Read, Grep`)两种写法。缺省表示允许全部工具;单独的 `*` 同样表示允许全部工具;空列表(`tools: []`)表示禁用全部工具 | | `disallowedTools` | 否 | 禁止列表,写法与匹配规则相同,在 `tools` 之后应用 | | `subagents` | 否 | 允许委派的子 Agent 名称列表,写法与 `tools` 相同(YAML 列表或逗号分隔字符串)。缺省表示可委派所有类型;单独的 `*` 同样表示全部 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 199ca0a2248..34f37759b9b 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -16,7 +16,7 @@ | `/logout` | — | 清除当前所选账号的凭据 | 否 | | `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见[平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-—-交互式供应商管理) | 是 | | `/model` | — | 切换当前会话使用的 LLM 模型 | 是 | -| `/secondary_model` | — | 配置子 Agent 使用的第二模型(写入 [`[secondary_model]`](../configuration/config-files.md#secondary-model) 配置并在当前会话立即生效)。需开启 `secondary-model` 实验功能 | 是 | +| `/secondary_model` | — | 配置子 Agent 默认绑定的次主力模型(写入 [`[secondary_model]`](../configuration/config-files.md#secondary-model) 配置并在当前会话立即生效)。需开启 `secondary-model` 实验功能 | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | | `/experiments` | `/experimental` | 打开实验功能面板 | 是 | | `/permission` | — | 选择权限模式 | 是 | From aff5f844a55083718c248eebd5cb14f07e7b5954 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 3 Aug 2026 16:37:55 +0800 Subject: [PATCH 06/33] fix(tui): make the /login already-logged-in notice visible (#2559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Already logged in. Model configuration refreshed." confirmation was rendered with the default dim text color, so users easily missed it and assumed /login did nothing. Render it with the theme's success color, matching the success styling used by the login spinner's "✓ Logged in." line. Co-authored-by: Mira Bot --- .changeset/login-already-logged-in-visibility.md | 4 ++++ apps/kimi-code/src/tui/commands/auth.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100755 .changeset/login-already-logged-in-visibility.md diff --git a/.changeset/login-already-logged-in-visibility.md b/.changeset/login-already-logged-in-visibility.md new file mode 100755 index 00000000000..265ebed5ac0 --- /dev/null +++ b/.changeset/login-already-logged-in-visibility.md @@ -0,0 +1,4 @@ +--- +"@moonshot-ai/kimi-code": patch +--- +Render the "/login" already-logged-in confirmation in the success color instead of dim text, so the "Already logged in. Model configuration refreshed." message is clearly visible. diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 773638485fa..a44b4fab5df 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -74,7 +74,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { already_logged_in: alreadyLoggedIn, }); if (alreadyLoggedIn) { - host.showStatus('Already logged in. Model configuration refreshed.'); + host.showStatus('Already logged in. Model configuration refreshed.', 'success'); } } catch (error) { const cancelled = controller.signal.aborted; From 715003a9502852bf346a35a469c3833950ee0c03 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 3 Aug 2026 17:14:34 +0800 Subject: [PATCH 07/33] feat(agent-core-v2): add lifecycle hook events and enrich hook payloads (#2558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-core-v2): add lifecycle hook events and enrich hook payloads New hook events: - TurnStarted: fired from the turn.started bus event, covering queued turns, stop-hook continuations, and background/system turns that UserPromptSubmit misses - UserPromptQueued: fired when a prompt cannot launch immediately, carrying the queue length - TaskStarted: fired from the existing task.started bus event, so background tasks no longer only produce a completion-time Notification - SessionHeartbeat: per-session 60s liveness beat, armed only when the event has hooks registered, letting hook consumers distinguish a session hanging on a long permission wait from a crashed one Payload enrichment: - client_type (host platform identity) on every event - session_title on every session/agent-scoped event - model and profile on SessionStart - SessionEnd reason is now 'exit' or 'archive' instead of a hardcoded 'exit' - SubagentStart/SubagentStop now carry session_id/cwd like every other event * fix(agent-core-v2): re-sync SessionHeartbeat timer on hook-index reloads The heartbeat timer was armed once after the runner's initial load, so a SessionHeartbeat hook contributed later by a plugin reload never produced beats for existing sessions. The runner now exposes onDidReload (fired after every index build), and the session adapter re-syncs on it: arming when a heartbeat hook appears, disarming when none remains. * fix(node-sdk): keep the v1 PluginInfo contract assignable with v2-only hook events The v2 hook-event union is now a superset of v1's, which broke the node-sdk type projection in two places: - the klient contract's hookDefSchema rejected plugin manifests using the new events (TurnStarted, UserPromptQueued, TaskStarted, SessionHeartbeat) at validation time — accept them - getPluginInfo returned the v2 PluginInfo where the SDK contract promises the v1 shape — project manifest.hooks through the v1-known event list (read from the legacy HookDefSchema), mirroring how the config mapper drops domains v1 does not know --- .changeset/hook-events-lifecycle-coverage.md | 5 + .../agent-core-v2/docs/config-manifest.toml | 2 +- .../externalHooks/externalHooksService.ts | 95 +++- .../src/agent/externalHooks/types.ts | 4 + .../src/agent/prompt/promptService.ts | 8 + .../externalHooksRunner.ts | 5 + .../externalHooksRunnerService.ts | 17 +- .../externalHooks/externalHooksService.ts | 98 ++++- .../sessionLifecycleHooks.ts | 2 +- .../sessionLifecycleService.ts | 2 +- .../test/agent/externalHooks/runner-stub.ts | 6 +- .../test/agent/prompt/promptService.test.ts | 16 +- .../externalHooksRunner.test.ts | 52 +++ .../externalHooksRunner/integration.test.ts | 411 +++++++++++++++++- packages/agent-core-v2/test/harness/agent.ts | 12 +- .../klient/src/contract/global/plugins.ts | 4 + packages/node-sdk/src/sdk-rpc-client-v2.ts | 18 +- 17 files changed, 728 insertions(+), 29 deletions(-) create mode 100644 .changeset/hook-events-lifecycle-coverage.md diff --git a/.changeset/hook-events-lifecycle-coverage.md b/.changeset/hook-events-lifecycle-coverage.md new file mode 100644 index 00000000000..0ba5e2aa219 --- /dev/null +++ b/.changeset/hook-events-lifecycle-coverage.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the TurnStarted, UserPromptQueued, TaskStarted, and SessionHeartbeat hook events, enrich hook payloads with the session title and client type, include the model and profile in SessionStart, and report SessionEnd as archive when a session is archived instead of exited. Configure the new events under [[hooks]] in config.toml. diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 8092e735f15..2fdf45d6d0d 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -129,7 +129,7 @@ extra_skill_dirs = [] # one [[hooks]] table per entry: # [[hooks]] - # event: "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" | "PermissionResult" | "UserPromptSubmit" | "Stop" | "StopFailure" | "Interrupt" | "SessionStart" | "SessionEnd" | "SubagentStart" | "SubagentStop" | "PreCompact" | "PostCompact" | "Notification" + # event: "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" | "PermissionResult" | "UserPromptSubmit" | "UserPromptQueued" | "TurnStarted" | "Stop" | "StopFailure" | "Interrupt" | "SessionStart" | "SessionEnd" | "SessionHeartbeat" | "SubagentStart" | "SubagentStop" | "TaskStarted" | "PreCompact" | "PostCompact" | "Notification" # matcher: string # command: string # timeout: integer diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts index d003e98cb20..7c3c30704b5 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts @@ -7,7 +7,11 @@ * `fullCompaction`, and `task`) and translates those minimal contexts into the * configured external hook commands, run through the shared App-scope * `IExternalHooksRunnerService` (so this adapter never owns an engine lifecycle - * of its own). Appends + * of its own). This includes the bus-driven lifecycle signals + * `turn.started` → `TurnStarted`, `prompt.queued` → `UserPromptQueued`, and + * `task.started` → `TaskStarted`. Every payload it sends is enriched with the + * cached session title (seeded from and kept fresh by `ISessionMetadata`). + * Appends * UserPromptSubmit hook results through `contextMemory`, drives Stop hook * continuations by enqueueing a mergeable `StepRequest` onto `loop`, and * passes the current session id from `sessionContext` @@ -23,7 +27,7 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { defineState } from '#/_base/state/stateRegistry'; import { isPlainRecord } from '#/_base/utils/canonical-args'; import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentTaskService, type AgentTaskNotificationContext } from '#/agent/task/task'; +import { IAgentTaskService, type AgentTaskInfo, type AgentTaskNotificationContext } from '#/agent/task/task'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { @@ -37,7 +41,7 @@ import { IAgentPromptService, type PromptSubmitContext, } from '#/agent/prompt/prompt'; -import type { TurnEndedEvent } from '#/agent/loop/turnEvents'; +import type { TurnEndedEvent, TurnStartedEvent } from '#/agent/loop/turnEvents'; import { IEventBus } from '#/app/event/eventBus'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ResolvedToolExecutionHookContext, ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; @@ -45,9 +49,11 @@ import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { toKimiErrorPayload } from '#/errors'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { IAgentExternalHooksService } from './externalHooks'; import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; +import type { HookMatcherValue } from './types'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult, @@ -81,13 +87,37 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter @IEventBus private readonly eventBus: IEventBus, @IInstantiationService private readonly instantiation: IInstantiationService, @ISessionContext private readonly sessionContext: ISessionContext, + @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @IAgentStateService private readonly states: IAgentStateService, ) { super(); this.states.register(externalHooksStopHookContinuationUsedKey); + void this.sessionMetadata + .read() + .then((meta) => { + this.sessionTitle = meta.title; + }) + .catch(() => undefined); + this._register( + this.sessionMetadata.onDidChangeMetadata((event) => { + if (!event.changed.includes('title')) return; + void this.sessionMetadata + .read() + .then((meta) => { + this.sessionTitle = meta.title; + }) + .catch(() => undefined); + }), + ); this.registerListeners(); } + private sessionTitle: string | undefined; + + private withSessionFacts(inputData: Record): Record { + return { sessionTitle: this.sessionTitle, ...inputData }; + } + private get stopHookContinuationUsed(): boolean { return this.states.get(externalHooksStopHookContinuationUsedKey); } @@ -99,7 +129,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter private fireAndForget( event: string, inputData: Record, - matcherValue?: string, + matcherValue?: HookMatcherValue, signal?: AbortSignal, ): void { try { @@ -107,7 +137,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter matcherValue, signal, sessionId: this.sessionContext.sessionId, - inputData, + inputData: this.withSessionFacts(inputData), }); } catch {} } @@ -180,14 +210,39 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter await next(); }), ); + this._register( + this.eventBus.subscribe('prompt.queued', (e) => { + this.fireAndForget( + 'UserPromptQueued', + { promptId: e.promptId, prompt: e.content, queueLength: e.queueLength }, + e.content, + ); + }), + ); } private registerTurnHooks(): void { + this._register( + this.eventBus.subscribe('turn.started', (e) => this.notifyTurnStarted(e)), + ); this._register( this.eventBus.subscribe('turn.ended', (e) => this.notifyTurnEnded(e)), ); } + private notifyTurnStarted(event: TurnStartedEvent): void { + this.fireAndForget( + 'TurnStarted', + { + turnId: event.turnId, + originKind: event.origin.kind, + originName: 'name' in event.origin ? event.origin.name : undefined, + prompt: event.prompt, + }, + event.origin.kind, + ); + } + private registerLoopHooks(loop: IAgentLoopService): void { this._register( loop.hooks.onDidFinishStep.register('externalHooks', async (ctx, next) => { @@ -240,6 +295,24 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter this.notifyTaskNotification(ctx); }), ); + this._register( + this.eventBus.subscribe('task.started', (e) => this.notifyTaskStarted(e.info)), + ); + } + + private notifyTaskStarted(info: AgentTaskInfo): void { + this.fireAndForget( + 'TaskStarted', + { + taskId: info.taskId, + kind: info.kind, + description: info.description, + status: info.status, + detached: info.detached, + startedAt: info.startedAt, + }, + info.kind, + ); } private async runPreToolUse(ctx: ResolvedToolExecutionHookContext): Promise { @@ -249,11 +322,11 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter matcherValue: ctx.toolCall.name, signal: ctx.signal, sessionId: this.sessionContext.sessionId, - inputData: { + inputData: this.withSessionFacts({ toolName: ctx.toolCall.name, toolInput, toolCallId: ctx.toolCall.id, - }, + }), }); ctx.signal.throwIfAborted(); return block?.reason; @@ -288,7 +361,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter matcherValue: input, signal, sessionId: this.sessionContext.sessionId, - inputData: { prompt: input, isSteer: ctx.isSteer }, + inputData: this.withSessionFacts({ prompt: input, isSteer: ctx.isSteer }), }); signal.throwIfAborted(); @@ -356,7 +429,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter const block = await this.runner.triggerBlock('Stop', { signal: ctx.signal, sessionId: this.sessionContext.sessionId, - inputData: { stopHookActive: false }, + inputData: this.withSessionFacts({ stopHookActive: false }), }); ctx.signal.throwIfAborted(); return block?.reason; @@ -369,10 +442,10 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter matcherValue: ctx.trigger, signal, sessionId: this.sessionContext.sessionId, - inputData: { + inputData: this.withSessionFacts({ trigger: ctx.trigger, tokenCount: ctx.tokenCount, - }, + }), }); signal.throwIfAborted(); } diff --git a/packages/agent-core-v2/src/agent/externalHooks/types.ts b/packages/agent-core-v2/src/agent/externalHooks/types.ts index e4d7ac411d3..1cab03130b1 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/types.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/types.ts @@ -7,13 +7,17 @@ export const HOOK_EVENT_TYPES = [ 'PermissionRequest', 'PermissionResult', 'UserPromptSubmit', + 'UserPromptQueued', + 'TurnStarted', 'Stop', 'StopFailure', 'Interrupt', 'SessionStart', 'SessionEnd', + 'SessionHeartbeat', 'SubagentStart', 'SubagentStop', + 'TaskStarted', 'PreCompact', 'PostCompact', 'Notification', diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index bb38ac23d70..3f3dd91d100 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -51,6 +51,7 @@ declare module '#/app/event/eventBus' { 'prompt.completed': { type: 'prompt.completed'; promptId: string; finishedAt: string; reason: 'completed' | 'failed' | 'blocked' }; 'prompt.aborted': { type: 'prompt.aborted'; promptId: string; abortedAt: string }; 'prompt.steered': { type: 'prompt.steered'; activePromptId: string; promptIds: string[]; content: ContentPart[]; steeredAt: string }; + 'prompt.queued': { type: 'prompt.queued'; promptId: string; content: ContentPart[]; queueLength: number }; } } @@ -116,10 +117,13 @@ export class AgentPromptService implements IAgentPromptService { this.pending.push(record); if (this.active === undefined && !this.launching) { if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { + this.publishQueued(record); return record.handle; } void this.startNext(); await Promise.race([record.launchedDeferred.promise, record.completionDeferred.promise]); + } else { + this.publishQueued(record); } return record.handle; } @@ -254,6 +258,10 @@ export class AgentPromptService implements IAgentPromptService { if (delivery.kind === 'steer') await this.inject(delivery.message as ContextMessage); } private publishCompleted(promptId: string, reason: 'completed' | 'failed' | 'blocked'): void { this.eventBus.publish({ type: 'prompt.completed', promptId, finishedAt: new Date().toISOString(), reason }); } + private publishQueued(record: Record): void { + if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; + this.eventBus.publish({ type: 'prompt.queued', promptId: record.id, content: record.message.content, queueLength: this.pending.length }); + } private publishAborted(promptId: string): void { this.eventBus.publish({ type: 'prompt.aborted', promptId, abortedAt: new Date().toISOString() }); } } diff --git a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts index cb3bd10ea73..95d273dde05 100644 --- a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts +++ b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunner.ts @@ -10,6 +10,7 @@ */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; import type { HookBlockDecision, HookMatcherValue, HookResult } from '#/agent/externalHooks/types'; export interface ExternalHooksRunnerTriggerArgs { @@ -22,12 +23,16 @@ export interface ExternalHooksRunnerTriggerArgs { export interface IExternalHooksRunnerService { readonly _serviceBrand: undefined; + readonly ready: Promise; + /** Fired after the hook index is (re)built — initial load and plugin reloads. */ + readonly onDidReload: Event; trigger(event: string, args?: ExternalHooksRunnerTriggerArgs): Promise; triggerBlock( event: string, args?: ExternalHooksRunnerTriggerArgs, ): Promise; fireAndForgetTrigger(event: string, args?: ExternalHooksRunnerTriggerArgs): Promise; + hasHooksFor(event: string): boolean; } export const IExternalHooksRunnerService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts index 20e87209538..2586df30d37 100644 --- a/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts +++ b/packages/agent-core-v2/src/app/externalHooksRunner/externalHooksRunnerService.ts @@ -9,11 +9,14 @@ * process service (cross-platform kill, hidden console on Windows) rather than * `node:child_process` directly. Per-call caller facts (`cwd` defaulting to * bootstrap cwd, `sessionId`, `signal`, payload) flow in through the args, so - * this service keeps no per-scope state. Bound at App scope. + * this service keeps no per-scope state; the one payload field it contributes + * itself is `clientType` (the host platform from bootstrap client identity), + * merged under the caller's `inputData`. Bound at App scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter, type Event } from '#/_base/event'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IPluginService } from '#/app/plugin/plugin'; @@ -34,6 +37,9 @@ export class ExternalHooksRunnerService extends Disposable implements IExternalH private byEvent = new Map(); readonly ready: Promise; + private readonly _onDidReload = this._register(new Emitter()); + readonly onDidReload: Event = this._onDidReload.event; + constructor( @IConfigService private readonly config: IConfigService, @IPluginService private readonly plugins: IPluginService, @@ -84,6 +90,10 @@ export class ExternalHooksRunnerService extends Disposable implements IExternalH } } + hasHooksFor(event: string): boolean { + return (this.byEvent.get(event)?.length ?? 0) > 0; + } + private async triggerInner( event: string, args: ExternalHooksRunnerTriggerArgs, @@ -96,6 +106,10 @@ export class ExternalHooksRunnerService extends Disposable implements IExternalH { cwd: args.cwd ?? this.bootstrap.cwd, ...args, + inputData: { + clientType: this.bootstrap.clientIdentity.platform, + ...args.inputData, + }, }, this.callbacks, ); @@ -118,6 +132,7 @@ export class ExternalHooksRunnerService extends Disposable implements IExternalH const configured = this.config.get(HOOKS_SECTION) as readonly HookDefConfig[] | undefined; const pluginHooks = await this.plugins.enabledHooks(); this.byEvent = indexHooks([...(configured ?? []), ...pluginHooks]); + this._onDidReload.fire(); } } diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts index d4d68a70825..53a390eb7cd 100644 --- a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -9,7 +9,12 @@ * observes the requester-side agent-run hook slot (`onWillStartAgentTask`) and * stop event (`onDidStopAgentTask`) hosted on the `subagent` domain's * `ISessionSubagentService` to translate them into the `SubagentStart` / - * `SubagentStop` external commands. The slot/event host lives on the service + * `SubagentStop` external commands. It also owns the periodic + * `SessionHeartbeat` command (one timer per session, ticking only when the + * event is configured), enriches every payload it sends with the cached + * session title (seeded from and kept fresh by `ISessionMetadata`), and + * resolves the SessionStart model/profile facts from `IModelService` / + * `ISessionAgentProfileCatalog`. The slot/event host lives on the service * that owns the run; this adapter only registers its * own listeners here, so the runner owns the slots it runs — the same pattern * the Agent-scope adapter follows against the agent behavior services. The @@ -20,8 +25,13 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IntervalTimer } from '#/_base/utils/timer'; import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; import type { Hooks } from '#/hooks'; +import { IModelService } from '#/kosong/model/model'; +import { + ISessionAgentProfileCatalog, +} from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionLifecycleHooks, @@ -29,6 +39,7 @@ import { type SessionCreateSource, type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { type AgentTaskStartHookContext, type AgentTaskStopHookContext, @@ -39,19 +50,44 @@ import { ISessionExternalHooksService } from './externalHooks'; type SessionStartHookSource = Exclude; +const HEARTBEAT_INTERVAL_MS = 60_000; + export class SessionExternalHooksService extends Disposable implements ISessionExternalHooksService { declare readonly _serviceBrand: undefined; + private sessionTitle: string | undefined; + private readonly createdAt = Date.now(); + constructor( @ISessionContext private readonly context: ISessionContext, @ISessionLifecycleHooks lifecycleHooks: Hooks, @ISessionSubagentService subagents: ISessionSubagentService, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @ISessionAgentProfileCatalog private readonly profiles: ISessionAgentProfileCatalog, + @IModelService private readonly models: IModelService, @IExternalHooksRunnerService private readonly runner: IExternalHooksRunnerService, ) { super(); + void this.metadata + .read() + .then((meta) => { + this.sessionTitle = meta.title; + }) + .catch(() => undefined); + this._register( + this.metadata.onDidChangeMetadata((event) => { + if (!event.changed.includes('title')) return; + void this.metadata + .read() + .then((meta) => { + this.sessionTitle = meta.title; + }) + .catch(() => undefined); + }), + ); this._register( lifecycleHooks.onDidCreateSession.register('externalHooks', async (event, next) => { if (event.source !== 'fork') { @@ -73,6 +109,28 @@ export class SessionExternalHooksService }), ); this._register(subagents.onDidStopAgentTask((ctx) => this.notifySubagentStop(ctx))); + + // Arm the heartbeat only once the configured-hook index has loaded and + // only when the event has hooks at all, so sessions without a + // SessionHeartbeat hook never hold a recurring timer. Re-sync on every + // hook-index reload (plugin reload) so late-registered heartbeat hooks + // still arm, and removed ones disarm. + void this.runner.ready + .then(() => this.syncHeartbeat()) + .catch(() => undefined); + this._register(this.runner.onDidReload(() => this.syncHeartbeat())); + } + + private readonly heartbeat = this._register(new IntervalTimer({ unref: true })); + + private syncHeartbeat(): void { + try { + if (this.runner.hasHooksFor('SessionHeartbeat')) { + this.heartbeat.cancelAndSet(() => this.tickHeartbeat(), HEARTBEAT_INTERVAL_MS); + } else { + this.heartbeat.cancel(); + } + } catch {} } private async triggerSessionStart(source: SessionStartHookSource): Promise { @@ -80,27 +138,58 @@ export class SessionExternalHooksService matcherValue: source, cwd: this.context.cwd, sessionId: this.context.sessionId, - inputData: { source }, + inputData: { + source, + sessionTitle: this.sessionTitle, + model: this.models.getDefaultModel(), + profile: await this.defaultProfileName(), + }, }); } + private async defaultProfileName(): Promise { + try { + await this.profiles.ready; + return this.profiles.getDefault().name; + } catch { + return undefined; + } + } + private async triggerSessionEnd(reason: SessionCloseReason): Promise { await this.runner.trigger('SessionEnd', { matcherValue: reason, cwd: this.context.cwd, sessionId: this.context.sessionId, - inputData: { reason }, + inputData: { reason, sessionTitle: this.sessionTitle }, }); } + private tickHeartbeat(): void { + try { + if (!this.runner.hasHooksFor('SessionHeartbeat')) return; + void this.runner.fireAndForgetTrigger('SessionHeartbeat', { + cwd: this.context.cwd, + sessionId: this.context.sessionId, + inputData: { + sessionTitle: this.sessionTitle, + uptimeMs: Date.now() - this.createdAt, + }, + }); + } catch {} + } + private async runSubagentStart(ctx: AgentTaskStartHookContext): Promise { ctx.signal.throwIfAborted(); await this.runner.trigger('SubagentStart', { matcherValue: ctx.agentName, signal: ctx.signal, + cwd: this.context.cwd, + sessionId: this.context.sessionId, inputData: { agentName: ctx.agentName, prompt: ctx.prompt, + sessionTitle: this.sessionTitle, }, }); ctx.signal.throwIfAborted(); @@ -109,9 +198,12 @@ export class SessionExternalHooksService private notifySubagentStop(ctx: AgentTaskStopHookContext): void { void this.runner.fireAndForgetTrigger('SubagentStop', { matcherValue: ctx.agentName, + cwd: this.context.cwd, + sessionId: this.context.sessionId, inputData: { agentName: ctx.agentName, response: ctx.response, + sessionTitle: this.sessionTitle, }, }); } diff --git a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts index a580deccc5b..dabb8a0cf49 100644 --- a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts +++ b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts @@ -13,7 +13,7 @@ import type { Hooks } from '#/hooks'; export type SessionCreateSource = 'startup' | 'resume' | 'fork'; -export type SessionCloseReason = 'exit'; +export type SessionCloseReason = 'exit' | 'archive'; export interface SessionStartHookEvent { readonly source: SessionCreateSource; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 3b8effec93a..955c1c6f727 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -386,7 +386,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec type: 'event.session.archived', payload: { sessionId }, }); - await this.announceWillClose({ sessionId, handle, reason: 'exit' }); + await this.announceWillClose({ sessionId, handle, reason: 'archive' }); this.sessions.delete(sessionId); handle.dispose(); this._onDidArchiveSession.fire({ sessionId }); diff --git a/packages/agent-core-v2/test/agent/externalHooks/runner-stub.ts b/packages/agent-core-v2/test/agent/externalHooks/runner-stub.ts index 9799e001dd7..f2052b52b02 100644 --- a/packages/agent-core-v2/test/agent/externalHooks/runner-stub.ts +++ b/packages/agent-core-v2/test/agent/externalHooks/runner-stub.ts @@ -44,7 +44,11 @@ export function makeHookRunner( enabledHooks: async () => [], onDidReload: Event.None as IPluginService['onDidReload'], } as unknown as IPluginService, - { _serviceBrand: undefined, cwd: options.cwd ?? '' } as unknown as IBootstrapService, + { + _serviceBrand: undefined, + cwd: options.cwd ?? '', + clientIdentity: { productName: 'test', version: '0.0.0-test', platform: 'test_platform' }, + } as unknown as IBootstrapService, new HostProcessService(), { onTriggered: options.onTriggered, onResolved: options.onResolved }, ); 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 013b9198306..7336d31c915 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -59,7 +59,7 @@ function harness() { reg.define(IAgentPromptService, AgentPromptService); } }); - return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction }; + return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus) }; } describe('AgentPromptService', () => { @@ -79,6 +79,20 @@ describe('AgentPromptService', () => { expect(prompt.list().pending.map((item) => item.id)).toEqual([first.id, second.id]); }); + it('publishes prompt.queued only for prompts that cannot launch immediately', async () => { + const { prompt, eventBus } = harness(); + const queued: Array<{ promptId: string; queueLength: number }> = []; + eventBus.subscribe('prompt.queued', (e) => { + queued.push({ promptId: e.promptId, queueLength: e.queueLength }); + }); + + await prompt.enqueue({ id: 'active', message: message('active') }); + expect(queued).toEqual([]); + + await prompt.enqueue({ id: 'waiting', message: message('waiting') }); + expect(queued).toEqual([{ promptId: 'waiting', queueLength: 1 }]); + }); + it('atomically rejects steer when any id is not pending', async () => { const { prompt } = harness(); await prompt.enqueue({ message: message('active') }); diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/externalHooksRunner.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/externalHooksRunner.test.ts index 38ec1ff7a23..86809fa587f 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/externalHooksRunner.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/externalHooksRunner.test.ts @@ -289,4 +289,56 @@ describe('ExternalHooksRunnerService', () => { expect(results).toHaveLength(1); expect(results[0]?.action).toBe('block'); }); + + it('injects the bootstrap client platform as client_type into every payload', async () => { + const runner = makeHookRunner([ + { + event: 'SessionStart', + command: nodeCommand([ + 'let input = "";', + 'process.stdin.on("data", (chunk) => { input += chunk; });', + 'process.stdin.on("end", () => {', + ' process.stdout.write(String(JSON.parse(input).client_type));', + '});', + ].join('\n')), + timeout: 5, + }, + ]); + + const results = await runner.trigger('SessionStart', { inputData: {} }); + expect(results[0]?.stdout?.trim()).toBe('test_platform'); + }); + + it('lets the caller override clientType in inputData', async () => { + const runner = makeHookRunner([ + { + event: 'SessionStart', + command: nodeCommand([ + 'let input = "";', + 'process.stdin.on("data", (chunk) => { input += chunk; });', + 'process.stdin.on("end", () => {', + ' process.stdout.write(String(JSON.parse(input).client_type));', + '});', + ].join('\n')), + timeout: 5, + }, + ]); + + const results = await runner.trigger('SessionStart', { + inputData: { clientType: 'custom_client' }, + }); + expect(results[0]?.stdout?.trim()).toBe('custom_client'); + }); + + it('reports hook presence through hasHooksFor', async () => { + const runner = makeHookRunner([ + { event: 'PreToolUse', matcher: 'Bash', command: 'echo 1' }, + { event: 'SessionHeartbeat', command: 'echo 2' }, + ]); + + await runner.ready; + expect(runner.hasHooksFor('PreToolUse')).toBe(true); + expect(runner.hasHooksFor('SessionHeartbeat')).toBe(true); + expect(runner.hasHooksFor('Stop')).toBe(false); + }); }); diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index bf76062689d..a7b5fbfd94d 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; @@ -38,6 +38,7 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; import { ExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunnerService'; import { makeHookRunner } from '../../agent/externalHooks/runner-stub'; +import type { AgentTaskInfo } from '#/agent/task/task'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; @@ -58,6 +59,11 @@ import { } from '#/session/subagent/subagent'; import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; import { SessionExternalHooksService } from '#/session/externalHooks/externalHooksService'; +import { + ISessionAgentProfileCatalog, +} from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { IModelService } from '#/kosong/model/model'; import { stubBootstrap } from '../bootstrap/stubs'; import { stubLoopWithHooks, stubToolExecutor } from '../../agent/loop/stubs'; @@ -128,14 +134,53 @@ async function flushMicrotasks(): Promise { } function stubHookRunner(partial: unknown): IExternalHooksRunnerService { - const p = partial as Pick< - IExternalHooksRunnerService, - 'trigger' | 'triggerBlock' | 'fireAndForgetTrigger' + const p = partial as Partial< + Pick< + IExternalHooksRunnerService, + 'trigger' | 'triggerBlock' | 'fireAndForgetTrigger' | 'hasHooksFor' + > >; return { _serviceBrand: undefined, + ready: Promise.resolve(), + onDidReload: Event.None, + hasHooksFor: () => false, ...p, - }; + } as IExternalHooksRunnerService; +} + +function stubSessionMetadata(title?: string): ISessionMetadata { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: Event.None, + read: async () => ({ + id: 'session-1', + title, + createdAt: 0, + updatedAt: 0, + archived: false, + }), + update: async () => {}, + setTitle: async () => {}, + setArchived: async () => {}, + registerAgent: async () => {}, + } as unknown as ISessionMetadata; +} + +function stubProfileCatalog(name = 'default'): ISessionAgentProfileCatalog { + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + getDefault: () => ({ name }), + } as unknown as ISessionAgentProfileCatalog; +} + +function stubModelService(model = 'kimi-test'): IModelService { + return { + _serviceBrand: undefined, + getDefaultModel: () => model, + } as unknown as IModelService; } function hookLogPath(): string { @@ -261,6 +306,7 @@ describe('IExternalHooksRunnerService integration', () => { registerTestAgentWireServices(reg, 'wire/external-hooks'); reg.defineInstance(IBootstrapService, stubBootstrap()); reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); reg.definePartialInstance(IConfigService, {}); reg.definePartialInstance(IPluginService, {}); reg.defineInstance(IAgentContextMemoryService, context); @@ -365,6 +411,7 @@ describe('IExternalHooksRunnerService integration', () => { registerTestAgentWireServices(reg, 'wire/external-hooks'); reg.defineInstance(IBootstrapService, stubBootstrap()); reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); reg.definePartialInstance(IConfigService, {}); reg.definePartialInstance(IPluginService, {}); reg.defineInstance(IAgentContextMemoryService, stubContextMemory()); @@ -491,6 +538,9 @@ describe('IExternalHooksRunnerService integration', () => { : `sessions/workspace-1/session-1/${subKey}`, }); reg.defineInstance(ISessionLifecycleHooks, stubSessionLifecycleHooks()); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); + reg.defineInstance(ISessionAgentProfileCatalog, stubProfileCatalog()); + reg.defineInstance(IModelService, stubModelService()); reg.definePartialInstance(ISessionSubagentService, { hooks: createHooks(['onWillStartAgentTask']), onDidStopAgentTask: stopAgentTask.event, @@ -554,6 +604,7 @@ describe('IExternalHooksRunnerService integration', () => { registerStateServices(reg); reg.defineInstance(IBootstrapService, stubBootstrap()); reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); reg.definePartialInstance(IConfigService, { ready, get: (domain: string): T => @@ -825,6 +876,9 @@ describe('IExternalHooksRunnerService integration', () => { : `sessions/workspace-1/session-1/${subKey}`, }); reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); + reg.defineInstance(ISessionAgentProfileCatalog, stubProfileCatalog()); + reg.defineInstance(IModelService, stubModelService()); reg.definePartialInstance(ISessionSubagentService, { hooks: createHooks(['onWillStartAgentTask']), onDidStopAgentTask: Event.None as Event, @@ -855,6 +909,7 @@ describe('IExternalHooksRunnerService integration', () => { await lifecycleHooks.onDidCreateSession.run({ source: 'resume' }); await lifecycleHooks.onDidCreateSession.run({ source: 'fork' }); await lifecycleHooks.onWillCloseSession.run({ reason: 'exit' }); + await lifecycleHooks.onWillCloseSession.run({ reason: 'archive' }); expect(readHookLog(path)).toEqual([ { @@ -875,6 +930,12 @@ describe('IExternalHooksRunnerService integration', () => { sessionId: 'session-1', cwd, }, + { + event: 'SessionEnd', + reason: 'archive', + sessionId: 'session-1', + cwd, + }, ]); } finally { ix?.dispose(); @@ -985,4 +1046,344 @@ describe('IExternalHooksRunnerService integration', () => { expect(stop).toHaveLength(1); expect(stop[0]?.stdout).toContain('stop:explore:done'); }); + + it('enriches SessionStart with model, profile, session title, and client type', async () => { + const disposables = new DisposableStore(); + let ix: TestInstantiationService | undefined; + try { + const lifecycleHooks = stubSessionLifecycleHooks(); + const path = hookLogPath(); + const command = stdinScript([ + 'const fs = require("node:fs");', + 'fs.appendFileSync(', + ` ${JSON.stringify(path)},`, + ' JSON.stringify({', + ' event: parsed.hook_event_name,', + ' model: parsed.model,', + ' profile: parsed.profile,', + ' sessionTitle: parsed.session_title,', + ' clientType: parsed.client_type,', + ' }) + "\\n",', + ');', + ].join('\n')); + + ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + registerStateServices(reg); + reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks); + reg.defineInstance(ISessionMetadata, stubSessionMetadata('My Session')); + reg.defineInstance(ISessionAgentProfileCatalog, stubProfileCatalog('coder')); + reg.defineInstance(IModelService, stubModelService('kimi-k2')); + reg.definePartialInstance(ISessionSubagentService, { + hooks: createHooks(['onWillStartAgentTask']), + onDidStopAgentTask: Event.None as Event, + }); + reg.definePartialInstance(IConfigService, { + ready: Promise.resolve(), + get: (domain: string): T => + (domain === HOOKS_SECTION + ? [{ event: 'SessionStart' as const, command, timeout: 5 }] + : undefined) as T, + }); + reg.definePartialInstance(IPluginService, { + enabledHooks: async () => [], + onDidReload: Event.None as IPluginService['onDidReload'], + }); + reg.defineInstance(IBootstrapService, stubBootstrap()); + reg.define(IHostProcessService, HostProcessService); + }, + }); + ix.set(IExternalHooksRunnerService, new SyncDescriptor(ExternalHooksRunnerService)); + ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); + ix.get(ISessionExternalHooksService); + await flushMicrotasks(); + + await lifecycleHooks.onDidCreateSession.run({ source: 'startup' }); + + expect(readHookLog(path)).toEqual([ + { + event: 'SessionStart', + model: 'kimi-k2', + profile: 'coder', + sessionTitle: 'My Session', + clientType: 'test_platform', + }, + ]); + } finally { + ix?.dispose(); + disposables.dispose(); + } + }); + + it('translates turn.started, prompt.queued, and task.started bus events into hooks', async () => { + const disposables = new DisposableStore(); + let ix: TestInstantiationService | undefined; + try { + const fired: Array<{ + event: string; + matcherValue?: unknown; + inputData?: unknown; + }> = []; + const hookEngine = { + trigger: async () => [], + triggerBlock: async () => undefined, + fireAndForgetTrigger: async ( + event: string, + args: { matcherValue?: unknown; inputData?: unknown }, + ) => { + fired.push({ + event, + matcherValue: args.matcherValue, + inputData: args.inputData, + }); + return []; + }, + }; + + ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + registerStateServices(reg); + registerTestAgentWireServices(reg, 'wire/external-hooks'); + reg.defineInstance(IBootstrapService, stubBootstrap()); + reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionMetadata, stubSessionMetadata('My Session')); + reg.definePartialInstance(IConfigService, {}); + reg.definePartialInstance(IPluginService, {}); + reg.defineInstance(IAgentContextMemoryService, stubContextMemory()); + reg.defineInstance(IAgentLoopService, stubLoopWithHooks()); + reg.define(IEventBus, EventBusService); + reg.definePartialInstance(IAgentPromptService, { + hooks: createHooks(['onBeforeSubmitPrompt']), + }); + reg.defineInstance(IAgentToolExecutorService, stubToolExecutor()); + reg.definePartialInstance(IAgentPermissionGate, {}); + reg.definePartialInstance(IAgentFullCompactionService, { + hooks: createHooks(['onWillCompact']), + }); + reg.definePartialInstance(IAgentTaskService, {}); + }, + }); + ix.set(IExternalHooksRunnerService, stubHookRunner(hookEngine)); + ix.set(IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService)); + ix.get(IAgentExternalHooksService); + const eventBus = ix.get(IEventBus); + await flushMicrotasks(); + + eventBus.publish({ + type: 'turn.started', + turnId: 3, + origin: { kind: 'system_trigger', name: 'goal' }, + }); + const queuedContent = [{ type: 'text' as const, text: 'later' }]; + eventBus.publish({ + type: 'prompt.queued', + promptId: 'p1', + content: queuedContent, + queueLength: 2, + }); + eventBus.publish({ + type: 'task.started', + info: { + taskId: 'task-1', + kind: 'process', + description: 'npm test', + status: 'running', + startedAt: 123, + } as unknown as AgentTaskInfo, + }); + await flushMicrotasks(); + + expect(fired).toEqual([ + { + event: 'TurnStarted', + matcherValue: 'system_trigger', + inputData: { + sessionTitle: 'My Session', + turnId: 3, + originKind: 'system_trigger', + originName: 'goal', + prompt: undefined, + }, + }, + { + event: 'UserPromptQueued', + matcherValue: queuedContent, + inputData: { + sessionTitle: 'My Session', + promptId: 'p1', + prompt: queuedContent, + queueLength: 2, + }, + }, + { + event: 'TaskStarted', + matcherValue: 'process', + inputData: { + sessionTitle: 'My Session', + taskId: 'task-1', + kind: 'process', + description: 'npm test', + status: 'running', + detached: undefined, + startedAt: 123, + }, + }, + ]); + } finally { + ix?.dispose(); + disposables.dispose(); + } + }); + + it('fires SessionHeartbeat on the interval when the event is configured', async () => { + vi.useFakeTimers(); + const disposables = new DisposableStore(); + let ix: TestInstantiationService | undefined; + try { + const fired: string[] = []; + const hookEngine = { + trigger: async () => [], + triggerBlock: async () => undefined, + fireAndForgetTrigger: async (event: string) => { + fired.push(event); + return []; + }, + hasHooksFor: (event: string) => event === 'SessionHeartbeat', + }; + + ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + registerStateServices(reg); + reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionLifecycleHooks, stubSessionLifecycleHooks()); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); + reg.defineInstance(ISessionAgentProfileCatalog, stubProfileCatalog()); + reg.defineInstance(IModelService, stubModelService()); + reg.definePartialInstance(ISessionSubagentService, { + hooks: createHooks(['onWillStartAgentTask']), + onDidStopAgentTask: Event.None as Event, + }); + }, + }); + ix.set(IExternalHooksRunnerService, stubHookRunner(hookEngine)); + ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); + ix.get(ISessionExternalHooksService); + + await vi.advanceTimersByTimeAsync(60_000); + expect(fired).toEqual(['SessionHeartbeat']); + await vi.advanceTimersByTimeAsync(60_000); + expect(fired).toEqual(['SessionHeartbeat', 'SessionHeartbeat']); + } finally { + ix?.dispose(); + disposables.dispose(); + vi.useRealTimers(); + } + }); + + it('skips SessionHeartbeat ticks when no hook is registered for the event', async () => { + vi.useFakeTimers(); + const disposables = new DisposableStore(); + let ix: TestInstantiationService | undefined; + try { + const fired: string[] = []; + const hookEngine = { + trigger: async () => [], + triggerBlock: async () => undefined, + fireAndForgetTrigger: async (event: string) => { + fired.push(event); + return []; + }, + }; + + ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + registerStateServices(reg); + reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionLifecycleHooks, stubSessionLifecycleHooks()); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); + reg.defineInstance(ISessionAgentProfileCatalog, stubProfileCatalog()); + reg.defineInstance(IModelService, stubModelService()); + reg.definePartialInstance(ISessionSubagentService, { + hooks: createHooks(['onWillStartAgentTask']), + onDidStopAgentTask: Event.None as Event, + }); + }, + }); + ix.set(IExternalHooksRunnerService, stubHookRunner(hookEngine)); + ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); + ix.get(ISessionExternalHooksService); + + await vi.advanceTimersByTimeAsync(180_000); + expect(fired).toEqual([]); + } finally { + ix?.dispose(); + disposables.dispose(); + vi.useRealTimers(); + } + }); + + it('arms and disarms SessionHeartbeat when the hook index reloads', async () => { + vi.useFakeTimers(); + const disposables = new DisposableStore(); + let ix: TestInstantiationService | undefined; + try { + const fired: string[] = []; + const reloadEmitter = disposables.add(new Emitter()); + let heartbeatEnabled = false; + const hookEngine = { + trigger: async () => [], + triggerBlock: async () => undefined, + fireAndForgetTrigger: async (event: string) => { + fired.push(event); + return []; + }, + hasHooksFor: (event: string) => heartbeatEnabled && event === 'SessionHeartbeat', + onDidReload: reloadEmitter.event, + }; + + ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + registerStateServices(reg); + reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionLifecycleHooks, stubSessionLifecycleHooks()); + reg.defineInstance(ISessionMetadata, stubSessionMetadata()); + reg.defineInstance(ISessionAgentProfileCatalog, stubProfileCatalog()); + reg.defineInstance(IModelService, stubModelService()); + reg.definePartialInstance(ISessionSubagentService, { + hooks: createHooks(['onWillStartAgentTask']), + onDidStopAgentTask: Event.None as Event, + }); + }, + }); + ix.set(IExternalHooksRunnerService, stubHookRunner(hookEngine)); + ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); + ix.get(ISessionExternalHooksService); + + // No heartbeat hook at startup: nothing fires. + await vi.advanceTimersByTimeAsync(120_000); + expect(fired).toEqual([]); + + // A plugin reload contributes a SessionHeartbeat hook: the timer arms. + heartbeatEnabled = true; + reloadEmitter.fire(); + await vi.advanceTimersByTimeAsync(60_000); + expect(fired).toEqual(['SessionHeartbeat']); + + // A later reload drops it again: the timer disarms. + heartbeatEnabled = false; + reloadEmitter.fire(); + await vi.advanceTimersByTimeAsync(120_000); + expect(fired).toEqual(['SessionHeartbeat']); + } finally { + ix?.dispose(); + disposables.dispose(); + vi.useRealTimers(); + } + }); }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 0d36e22c089..08168e12fb6 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -659,18 +659,24 @@ function resolveExternalHooksRunner( function isRunnerLike( value: Pick, ): value is IExternalHooksRunnerService { + const candidate = value as IExternalHooksRunnerService; return ( - typeof value.trigger === 'function' && - typeof value.triggerBlock === 'function' && - typeof value.fireAndForgetTrigger === 'function' + typeof candidate.trigger === 'function' && + typeof candidate.triggerBlock === 'function' && + typeof candidate.fireAndForgetTrigger === 'function' && + typeof candidate.hasHooksFor === 'function' && + candidate.ready instanceof Promise ); } const noopHookRunner: IExternalHooksRunnerService = { _serviceBrand: undefined, + ready: Promise.resolve(), + onDidReload: Event.None as Event, trigger: async () => [], triggerBlock: async () => undefined, fireAndForgetTrigger: async () => [], + hasHooksFor: () => false, }; export function permissionModeServices(mode: PermissionMode): TestAgentServiceOverride { diff --git a/packages/klient/src/contract/global/plugins.ts b/packages/klient/src/contract/global/plugins.ts index 3f288d21c8f..112011221f5 100644 --- a/packages/klient/src/contract/global/plugins.ts +++ b/packages/klient/src/contract/global/plugins.ts @@ -44,13 +44,17 @@ const hookDefSchema = z.object({ 'PermissionRequest', 'PermissionResult', 'UserPromptSubmit', + 'UserPromptQueued', + 'TurnStarted', 'Stop', 'StopFailure', 'Interrupt', 'SessionStart', 'SessionEnd', + 'SessionHeartbeat', 'SubagentStart', 'SubagentStop', + 'TaskStarted', 'PreCompact', 'PostCompact', 'Notification', diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 2190f852572..8458cc1ea0e 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -131,6 +131,7 @@ import { join } from 'node:path'; import { ensureConfigFile, ErrorCodes, + HookDefSchema, KimiError, limitAgentReplayByTurns, noopTelemetryClient, @@ -726,7 +727,22 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } override async getPluginInfo(id: string): Promise { - return this.klient.global.plugins.info(id); + // The v2 engine's hook-event union is a superset of v1's (`TurnStarted`, + // `UserPromptQueued`, `TaskStarted`, `SessionHeartbeat` are v2-only). The + // SDK contract keeps the v1 `PluginInfo` shape, so hooks using v2-only + // events are dropped from the projection — mirroring how the config + // mapper drops config domains v1 does not know. + const info = await this.klient.global.plugins.info(id); + const manifest = + info.manifest === undefined + ? undefined + : { + ...info.manifest, + hooks: info.manifest.hooks?.filter((hook) => + (HookDefSchema.shape.event.options as readonly string[]).includes(hook.event), + ) as NonNullable['hooks'], + }; + return { ...info, manifest }; } /** From 17af420912fc47aeb6a194bc3639d78c4e7f3c0a Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 3 Aug 2026 17:55:28 +0800 Subject: [PATCH 08/33] refactor(kap-server): own v1 message history and snapshot assembly (#2562) - move the v1 message protocol and projection out of the engine into kap-server and delete the engine-side messageLegacy edge adapter - add a shared message history loader that folds the main agent's wire journal into full history across compactions, backing both the messages routes and the snapshot endpoint - drop the disk-reading SnapshotReader fast path; assemble snapshots from engine services for cold and live sessions, removing the KIMI_SNAPSHOT_READER, KIMI_SNAPSHOT_TIMEOUT_MS and KIMI_SNAPSHOT_CACHE_LIMIT knobs - collect persisted wire record rebuild helpers in the transcript service --- .changeset/server-owns-v1-message-history.md | 7 + .changeset/snapshot-via-engine-services.md | 6 + .../src/agent/contextMemory/messageId.ts | 3 +- .../src/app/messageLegacy/errors.ts | 9 - .../src/app/messageLegacy/messageLegacy.ts | 47 -- .../app/messageLegacy/messageLegacyService.ts | 191 ------- packages/agent-core-v2/src/errors.ts | 3 - packages/agent-core-v2/src/index.ts | 4 - packages/agent-core-v2/src/session/errors.ts | 3 +- .../projector-tool-exchanges.test.ts | 23 - .../app/messageLegacy/messageLegacy.test.ts | 342 ----------- .../kap-server/src/protocol/events-zod.ts | 2 +- .../src/protocol/message.ts} | 8 +- .../kap-server/src/protocol/rest-message.ts | 2 +- .../kap-server/src/protocol/rest-prompt.ts | 2 +- .../kap-server/src/protocol/rest-session.ts | 2 +- .../kap-server/src/protocol/rest-snapshot.ts | 2 +- packages/kap-server/src/routes/messages.ts | 49 +- .../src/routes/registerApiV1Routes.ts | 3 - packages/kap-server/src/routes/sessions.ts | 2 +- packages/kap-server/src/routes/snapshot.ts | 112 ++-- .../src/services/messages/messageHistory.ts | 216 +++++++ .../services/messages}/messageProjection.ts | 13 +- .../kap-server/src/services/snapshot/index.ts | 10 - .../src/services/snapshot/snapshot.ts | 38 -- .../src/services/snapshot/snapshotConfig.ts | 37 -- .../src/services/snapshot/snapshotReader.ts | 332 ----------- .../services/transcript/transcriptService.ts | 13 +- .../src/services/transcript/wireRecords.ts | 36 ++ packages/kap-server/src/start.ts | 10 - .../kap-server/src/transport/ws/v1/events.ts | 2 +- .../messages/messageProjection.test.ts | 157 ++++++ packages/kap-server/test/snapshot.test.ts | 142 ++--- .../test/snapshotReader.unit.test.ts | 531 ------------------ .../kap-server/test/workspaceLayout.test.ts | 2 +- 35 files changed, 547 insertions(+), 1814 deletions(-) create mode 100644 .changeset/server-owns-v1-message-history.md create mode 100644 .changeset/snapshot-via-engine-services.md delete mode 100644 packages/agent-core-v2/src/app/messageLegacy/errors.ts delete mode 100644 packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts delete mode 100644 packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts delete mode 100644 packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts rename packages/{agent-core-v2/src/agent/contextMemory/protocolMessage.ts => kap-server/src/protocol/message.ts} (87%) create mode 100644 packages/kap-server/src/services/messages/messageHistory.ts rename packages/{agent-core-v2/src/agent/contextMemory => kap-server/src/services/messages}/messageProjection.ts (90%) delete mode 100644 packages/kap-server/src/services/snapshot/index.ts delete mode 100644 packages/kap-server/src/services/snapshot/snapshot.ts delete mode 100644 packages/kap-server/src/services/snapshot/snapshotConfig.ts delete mode 100644 packages/kap-server/src/services/snapshot/snapshotReader.ts create mode 100644 packages/kap-server/src/services/transcript/wireRecords.ts create mode 100644 packages/kap-server/test/services/messages/messageProjection.test.ts delete mode 100644 packages/kap-server/test/snapshotReader.unit.test.ts diff --git a/.changeset/server-owns-v1-message-history.md b/.changeset/server-owns-v1-message-history.md new file mode 100644 index 00000000000..aafd69b17b1 --- /dev/null +++ b/.changeset/server-owns-v1-message-history.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kap-server": patch +"@moonshot-ai/kimi-code": patch +--- + +Serve v1 message history from the server layer and drop the engine-side legacy message adapter; the /api/v1 message contract is unchanged. diff --git a/.changeset/snapshot-via-engine-services.md b/.changeset/snapshot-via-engine-services.md new file mode 100644 index 00000000000..86284a2c1be --- /dev/null +++ b/.changeset/snapshot-via-engine-services.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kap-server": patch +"@moonshot-ai/kimi-code": patch +--- + +Assemble the session snapshot endpoint from the engine's services for both cold and live sessions, and remove the KIMI_SNAPSHOT_READER, KIMI_SNAPSHOT_TIMEOUT_MS, and KIMI_SNAPSHOT_CACHE_LIMIT environment knobs. diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts index 5cabbe3de90..b764549d62f 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts @@ -4,7 +4,8 @@ * Local message ids (`msg_`) are process-lifetime identifiers only — * they are NOT persisted: the on-disk `context.append_message` record carries * exactly v1's field set, and public message ids are derived from the - * transcript index, which stays stable across live reads and resume. + * transcript index (by the server layer's `ContextMessage → wire Message` + * projection), which stays stable across live reads and resume. * `newMessageId` remains for callers that need an opaque per-process id. * Provider-assigned ids live on the separate `providerMessageId` field and * never collide with this namespace. diff --git a/packages/agent-core-v2/src/app/messageLegacy/errors.ts b/packages/agent-core-v2/src/app/messageLegacy/errors.ts deleted file mode 100644 index 6eeb1f5f54d..00000000000 --- a/packages/agent-core-v2/src/app/messageLegacy/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * `messageLegacy` domain error codes — v1-compatible message failures. - */ - -export const MessageLegacyErrors = { - codes: { - MESSAGE_NOT_FOUND: 'message.not_found', - }, -} as const; diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts deleted file mode 100644 index fec924be7bd..00000000000 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacy.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * `messageLegacy` domain (L7 edge adapter) — v1-compatible message history. - * - * Implements the legacy `GET /api/v1/sessions/{sid}/messages[/{mid}]` - * contract on top of the native v2 services. - * - * The native `IAgentContextMemoryService` (Agent scope) holds the model's - * CURRENT, folded context and is NOT the full transcript: after a compaction - * it collapses into `[...keptUserMessages, compaction_summary]`. The full - * transcript is reduced on demand by streaming the main agent's `wire.jsonl`; - * the service does not make every live Agent retain its raw journal in - * memory. Bound at App scope — a stateless dispatcher that resolves the - * target session/agent per call. - * - * Error contract (mapped at the route layer): - * - `session.not_found` → 40401 - * - `message.not_found` → 40403 - */ - -import type { Message, MessageRole } from '#/agent/contextMemory/protocolMessage'; - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export interface CursorQuery { - before_id?: string | undefined; - after_id?: string | undefined; - page_size?: number | undefined; -} - -export interface PageResponse { - items: T[]; - has_more: boolean; -} - -export interface MessageListQuery extends CursorQuery { - readonly role?: MessageRole; -} - -export interface IMessageLegacyService { - readonly _serviceBrand: undefined; - - list(sessionId: string, query: MessageListQuery): Promise>; - get(sessionId: string, messageId: string): Promise; -} - -export const IMessageLegacyService: ServiceIdentifier = - createDecorator('messageLegacyService'); diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts deleted file mode 100644 index b465fd5fd2b..00000000000 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * `messageLegacy` domain — `IMessageLegacyService` implementation. - * - * Stateless App-scope dispatcher: each call resolves the target session (and - * its main agent), sources the transcript, and projects it into the v1 wire - * shape. - * - * History is streamed from the main agent's append log after its pending wire - * writes are flushed. The journal is folded incrementally by the shared - * transcript reducer, keeping full history across compactions (inserting a - * summary marker instead of folding) — unlike the live - * `IAgentContextMemoryService.get()`, whose folded context collapses into - * `[...keptUserMessages, compaction_summary]` and would lose the prefix. - * `foldedLength` is what the live history length WOULD be from the journal's - * records; because the journal can trail the live context by a record within a - * single dispatch, anything beyond it is appended as the unflushed tail. - * Pagination, id derivation, and the role filter mirror the legacy v1 - * semantics. - */ - -import type { Message } from '#/agent/contextMemory/protocolMessage'; - -import type { PageResponse } from './messageLegacy'; - -import { - type IAgentScopeHandle, - LifecycleScope, - ScopeActivation, - registerScopedService, -} from '#/_base/di/scope'; -import { IAgentBlobService } from '#/agent/blob/agentBlobService'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { - createContextTranscriptReducer, - type ContextTranscript, -} from '#/agent/contextMemory/contextTranscript'; -import { toProtocolMessage } from '#/agent/contextMemory/messageProjection'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; -import { IWireService } from '#/wire/wire'; -import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; -import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; -import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup'; -import { - IInstantiationService, - type ServicesAccessor, -} from '#/_base/di/instantiation'; -import { ErrorCodes, Error2 } from '#/errors'; -import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; - -import { IMessageLegacyService, type MessageListQuery } from './messageLegacy'; - -const DEFAULT_PAGE_SIZE = 50; -const MAX_PAGE_SIZE = 100; - -export class MessageLegacyService implements IMessageLegacyService { - declare readonly _serviceBrand: undefined; - - private readonly services: ServicesAccessor; - - constructor( - @IInstantiationService instantiation: IInstantiationService, - @ISessionIndex private readonly index: ISessionIndex, - @IAppendLogStore private readonly appendLog: IAppendLogStore, - ) { - this.services = { - get: (id) => instantiation.invokeFunction((accessor) => accessor.get(id)), - }; - } - - async list(sessionId: string, query: MessageListQuery): Promise> { - const all = await this.loadMessages(sessionId); - const desc = [...all].reverse(); - - let pivotIndex = -1; - if (query.before_id !== undefined) { - pivotIndex = desc.findIndex((m) => m.id === query.before_id); - } else if (query.after_id !== undefined) { - pivotIndex = desc.findIndex((m) => m.id === query.after_id); - } - - let slice: Message[]; - if (query.before_id !== undefined && pivotIndex >= 0) { - slice = desc.slice(pivotIndex + 1); - } else if (query.after_id !== undefined && pivotIndex >= 0) { - slice = desc.slice(0, pivotIndex); - } else { - slice = desc; - } - - const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; - const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); - const page = slice.slice(0, pageSize); - const hasMore = slice.length > pageSize; - - const filtered = query.role !== undefined ? page.filter((m) => m.role === query.role) : page; - - return { items: filtered, has_more: hasMore }; - } - - async get(sessionId: string, messageId: string): Promise { - const all = await this.loadMessages(sessionId); - const entry = all.find((m) => m.id === messageId); - if (entry === undefined) { - throw new Error2( - ErrorCodes.MESSAGE_NOT_FOUND, - `message ${messageId} does not exist in session ${sessionId}`, - ); - } - return entry; - } - - private async loadMessages(sessionId: string): Promise { - const summary = await this.index.get(sessionId); - if (summary === undefined) { - throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); - } - - const session = await resumeSessionById(this.services, sessionId); - if (session === undefined) return []; - const agent = await ensureMainAgent(session); - - const transcript = await this.readTranscript(agent); - const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); - const merged = mergeLiveTail(transcript, contextMessages); - const entries = await this.rehydrate(agent, merged.messages); - - let previousMs = Number.NEGATIVE_INFINITY; - return entries.map((msg, index) => { - const baseMs = merged.times[index] ?? summary.createdAt + index; - const createdAtMs = Math.max(previousMs + 1, baseMs); - previousMs = createdAtMs; - return toProtocolMessage(sessionId, index, msg, summary.createdAt, createdAtMs); - }); - } - - private async rehydrate( - agent: IAgentScopeHandle, - messages: readonly ContextMessage[], - ): Promise { - const blobs = agent.accessor.get(IAgentBlobService); - let changed = false; - const out: ContextMessage[] = []; - for (const msg of messages) { - const content = await blobs.loadParts(msg.content); - if (content === msg.content) { - out.push(msg); - continue; - } - changed = true; - out.push({ ...msg, content: [...content] }); - } - return changed ? out : messages; - } - - private async readTranscript(agent: IAgentScopeHandle): Promise { - await agent.accessor.get(IWireService).flush(); - const scope = agent.accessor.get(IAgentScopeContext).scope(); - const reducer = createContextTranscriptReducer(); - for await (const record of this.appendLog.read(scope, AGENT_WIRE_RECORD_KEY)) { - reducer.add(record); - } - return reducer.result(); - } -} - -function mergeLiveTail( - transcript: ContextTranscript, - contextMessages: readonly ContextMessage[], -): { - readonly messages: readonly ContextMessage[]; - readonly times: readonly (number | undefined)[]; -} { - if (contextMessages.length <= transcript.foldedLength) { - return { messages: transcript.entries, times: transcript.times }; - } - const tail = contextMessages.slice(transcript.foldedLength); - return { - messages: [...transcript.entries, ...tail], - times: [...transcript.times, ...tail.map(() => undefined)], - }; -} - -registerScopedService( - LifecycleScope.App, - IMessageLegacyService, - MessageLegacyService, - ScopeActivation.OnScopeCreated, - 'messageLegacy', -); diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index 5ecfd33a549..16fd89a93fe 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -17,7 +17,6 @@ import { FullCompactionErrors } from '#/agent/fullCompaction/errors'; import { GoalErrors } from '#/agent/goal/errors'; import { LoopErrors } from '#/agent/loop/errors'; import { McpErrors } from '#/mcpCore/errors'; -import { MessageLegacyErrors } from '#/app/messageLegacy/errors'; import { ModelCatalogErrors } from '#/kosong/model/errors'; import { OsFsErrors } from '#/os/interface/hostFsErrors'; import { OsProcessErrors } from '#/os/interface/hostProcess'; @@ -52,7 +51,6 @@ export { FullCompactionErrors } from '#/agent/fullCompaction/errors'; export { GoalErrors } from '#/agent/goal/errors'; export { LoopErrors } from '#/agent/loop/errors'; export { McpErrors } from '#/mcpCore/errors'; -export { MessageLegacyErrors } from '#/app/messageLegacy/errors'; export { ModelCatalogErrors } from '#/kosong/model/errors'; export { OsFsErrors } from '#/os/interface/hostFsErrors'; export { OsProcessErrors } from '#/os/interface/hostProcess'; @@ -84,7 +82,6 @@ export const ErrorCodes = { ...GoalErrors.codes, ...LoopErrors.codes, ...McpErrors.codes, - ...MessageLegacyErrors.codes, ...ModelCatalogErrors.codes, ...OsFsErrors.codes, ...OsProcessErrors.codes, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 650e5e0beb9..024343c856d 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -489,7 +489,6 @@ export * from '#/agent/contextMemory/conversationUndoParticipants'; export * from '#/agent/contextMemory/conversationTime'; export * from '#/agent/contextMemory/loopEventFold'; export * from '#/agent/contextMemory/messageId'; -export * from '#/agent/contextMemory/messageProjection'; export * from '#/agent/contextMemory/contextTranscript'; export * from '#/agent/contextMemory/types'; export * from '#/agent/systemReminder/systemReminder'; @@ -551,9 +550,6 @@ export * from '#/agent/profile/profileService'; export * from '#/agent/profile/context'; export * from '#/agent/prompt/prompt'; export * from '#/agent/prompt/promptService'; -import '#/app/messageLegacy/errors'; -export * from '#/app/messageLegacy/messageLegacy'; -export * from '#/app/messageLegacy/messageLegacyService'; export * from '#/agent/replayBuilder/types'; export * from '#/agent/undo/undo'; export * from '#/agent/undo/undoService'; diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts index b38a25f229c..861518d477d 100644 --- a/packages/agent-core-v2/src/session/errors.ts +++ b/packages/agent-core-v2/src/session/errors.ts @@ -1,5 +1,6 @@ /** - * `session` domain error codes — shared across the session layer. + * `session` domain error codes — shared across the session layer + * (`sessionLifecycle` / `sessionLegacy`). */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index 8fab71d4b38..ad501f1c086 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -16,7 +16,6 @@ import { ILogService, type ILogger } from '#/_base/log/log'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector'; import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService'; -import { toProtocolMessage } from '#/agent/contextMemory/messageProjection'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; @@ -326,28 +325,6 @@ describe('projector tool-exchange normalization', () => { { type: 'text', text: `image result\n${note}` }, ]); expect(result.content).toEqual([{ type: 'text', text: 'image result' }]); - - const protocol = toProtocolMessage('session_1', 0, result, 0); - expect(protocol.content).toEqual([ - { type: 'tool_result', tool_call_id: 'call_image', output: 'image result' }, - ]); - }); - - it('passes raw media parts through as the tool_result output', () => { - const result: ContextMessage = { - role: 'tool', - content: [ - { type: 'text', text: 'image result' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, - ], - toolCalls: [], - toolCallId: 'call_media', - }; - - const protocol = toProtocolMessage('session_1', 0, result, 0); - expect(protocol.content).toEqual([ - { type: 'tool_result', tool_call_id: 'call_media', output: result.content }, - ]); }); it('renders v1 tool-result status at the model projection boundary', () => { diff --git a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts deleted file mode 100644 index f31118c6eba..00000000000 --- a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { toDisposable } from '#/_base/di/lifecycle'; -import { type IAgentScopeHandle, type ISessionScopeHandle, LifecycleScope } from '#/_base/di/scope'; -import { TestInstantiationService } from '#/_base/di/test'; -import { IAgentBlobService } from '#/agent/blob/agentBlobService'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import type { ContentPart } from '#/kosong/contract/message'; -import { type IAppendLogStore } from '#/persistence/interface/appendLogStore'; -import { IWireService } from '#/wire/wire'; -import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; -import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionCronService } from '#/session/cron/sessionCronService'; - -import { MessageLegacyService } from '#/app/messageLegacy/messageLegacyService'; - -function textMessage(role: ContextMessage['role'], text: string): ContextMessage { - return { role, content: [{ type: 'text', text }], toolCalls: [] }; -} - -function buildService(opts: { - readonly summary: SessionSummary; - readonly records: readonly Record[]; - readonly contextMessages: readonly ContextMessage[]; - readonly loadParts?: (parts: readonly ContentPart[]) => Promise; -}): MessageLegacyService { - const mainHandle = { - id: MAIN_AGENT_ID, - kind: LifecycleScope.Agent, - accessor: { - get: (token: unknown): unknown => { - if (token === IWireService) { - return { flush: async () => {} }; - } - if (token === IAgentScopeContext) { - return { scope: () => 'sessions/wd/s1/agents/main' }; - } - if (token === IAgentContextMemoryService) { - return { get: () => opts.contextMessages }; - } - if (token === IAgentBlobService) { - return { - loadParts: - opts.loadParts ?? - ((parts: readonly ContentPart[]) => Promise.resolve(parts)), - }; - } - throw new Error('unexpected main agent service access'); - }, - }, - dispose: () => {}, - } as unknown as IAgentScopeHandle; - - const sessionHandle = { - id: opts.summary.id, - kind: LifecycleScope.Session, - accessor: { - get: (token: unknown): unknown => { - if (token === IAgentLifecycleService) { - return { - create: async () => mainHandle, - get: (id: string) => (id === MAIN_AGENT_ID ? mainHandle : undefined), - }; - } - if (token === ISessionCronService) return {}; - throw new Error('unexpected session service access'); - }, - }, - dispose: () => {}, - } as unknown as ISessionScopeHandle; - - const workspaceLifecycle = { - handlerFor: () => - Promise.resolve({ - id: opts.summary.workspaceId, - kind: LifecycleScope.Workspace, - accessor: { - get: (token: unknown): unknown => { - if (token === ISessionLifecycleService) { - return { - resume: (sessionId: string) => - Promise.resolve(sessionId === opts.summary.id ? sessionHandle : undefined), - }; - } - throw new Error('unexpected workspace service access'); - }, - }, - dispose: () => {}, - }), - } as unknown as IWorkspaceLifecycleService; - - const index = { - get: (sessionId: string) => Promise.resolve(sessionId === opts.summary.id ? opts.summary : undefined), - } as unknown as ISessionIndex; - - const appendLog: IAppendLogStore = { - _serviceBrand: undefined, - append: () => {}, - read: async function* () { - for (const record of opts.records) yield record as R; - }, - rewrite: async () => {}, - flush: async () => {}, - close: async () => {}, - acquire: () => toDisposable(() => {}), - }; - - const instantiation = new TestInstantiationService(); - instantiation.stub(ISessionIndex, index); - instantiation.stub(IWorkspaceLifecycleService, workspaceLifecycle); - return new MessageLegacyService(instantiation, index, appendLog); -} - -describe('MessageLegacyService', () => { - const summary: SessionSummary = { - id: 's1', - workspaceId: 'wd', - createdAt: 1_000, - updatedAt: 1_000, - archived: false, - }; - - it('reduces the transcript from the streamed append log', async () => { - const user = textMessage('user', 'hi'); - const assistant = textMessage('assistant', 'hello'); - const svc = buildService({ - summary, - records: [ - { type: 'context.append_message', message: user }, - { type: 'context.append_message', message: assistant }, - ], - contextMessages: [user, assistant], - }); - - const page = await svc.list('s1', {}); - - expect(page.items.map((m) => m.role)).toEqual(['assistant', 'user']); - expect(page.items[1]?.content[0]).toEqual({ type: 'text', text: 'hi' }); - expect(page.has_more).toBe(false); - }); - - it('throws session.not_found for an unknown session id', async () => { - const svc = buildService({ summary, records: [], contextMessages: [] }); - await expect(svc.list('missing', {})).rejects.toMatchObject({ code: 'session.not_found' }); - }); - - it('resolves a single message by derived id', async () => { - const user = textMessage('user', 'hi'); - const assistant = textMessage('assistant', 'hello'); - const svc = buildService({ - summary, - records: [ - { type: 'context.append_message', message: user }, - { type: 'context.append_message', message: assistant }, - ], - contextMessages: [user, assistant], - }); - - const message = await svc.get('s1', 'msg_s1_000001'); - - expect(message.role).toBe('assistant'); - expect(message.content[0]).toEqual({ type: 'text', text: 'hello' }); - }); - - it('rehydrates blobref media URLs from restored journal records', async () => { - const blobRefPart = { - type: 'image_url', - imageUrl: { url: 'blobref:image/png;deadbeef' }, - } as unknown as ContentPart; - const hydratedPart = { - type: 'image_url', - imageUrl: { url: 'data:image/png;base64,AAAA' }, - } as unknown as ContentPart; - const svc = buildService({ - summary, - records: [ - { - type: 'context.append_message', - message: { role: 'user', content: [blobRefPart], toolCalls: [] }, - }, - ], - contextMessages: [], - loadParts: async (parts) => parts.map((p) => (p === blobRefPart ? hydratedPart : p)), - }); - - const page = await svc.list('s1', {}); - - // The restored `blobref:` URL is served inline, the same shape live - // emissions carry — not as a broken blobref: link. - expect(page.items[0]?.content[0]).toEqual({ - type: 'image', - source: { kind: 'url', url: 'data:image/png;base64,AAAA' }, - }); - }); - - it('projects a kimi-file video reference to a structured file source without leaking the path', async () => { - const videoPart = { - type: 'video_url', - videoUrl: { url: 'kimi-file://file_9?path=%2Fcache%2Fclip.mp4' }, - } as unknown as ContentPart; - const svc = buildService({ - summary, - records: [ - { - type: 'context.append_message', - message: { role: 'user', content: [videoPart], toolCalls: [] }, - }, - ], - contextMessages: [], - }); - - const page = await svc.list('s1', {}); - - expect(page.items[0]?.content[0]).toEqual({ - type: 'video', - source: { kind: 'file', file_id: 'file_9' }, - }); - }); - - it('projects a provider video url to a structured url source carrying its id', async () => { - const videoPart = { - type: 'video_url', - videoUrl: { url: 'ms://prov-7', id: 'prov-7' }, - } as unknown as ContentPart; - const svc = buildService({ - summary, - records: [ - { - type: 'context.append_message', - message: { role: 'user', content: [videoPart], toolCalls: [] }, - }, - ], - contextMessages: [], - }); - - const page = await svc.list('s1', {}); - - expect(page.items[0]?.content[0]).toEqual({ - type: 'video', - source: { kind: 'url', url: 'ms://prov-7', id: 'prov-7' }, - }); - }); - - it('passes media tool results through as raw content parts instead of flattening', async () => { - const mediaPart = { - type: 'image_url', - imageUrl: { url: 'data:image/png;base64,AAAA' }, - } as unknown as ContentPart; - const svc = buildService({ - summary, - records: [ - { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 'st1' } }, - { - type: 'context.append_loop_event', - event: { - type: 'tool.call', - stepUuid: 'st1', - toolCallId: 'call_1', - name: 'ReadMediaFile', - args: {}, - }, - }, - { - type: 'context.append_loop_event', - event: { - type: 'tool.result', - toolCallId: 'call_1', - result: { output: [mediaPart], isError: false }, - }, - }, - { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 'st1' } }, - ], - contextMessages: [], - }); - - const page = await svc.list('s1', {}); - - const toolMessage = page.items.find((m) => m.role === 'tool'); - expect(toolMessage?.content[0]).toEqual({ - type: 'tool_result', - tool_call_id: 'call_1', - output: [mediaPart], - }); - }); - - it('flattens text-only tool results to joined text', async () => { - const svc = buildService({ - summary, - records: [ - { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 'st1' } }, - { - type: 'context.append_loop_event', - event: { type: 'tool.call', stepUuid: 'st1', toolCallId: 'call_1', name: 'Bash' }, - }, - { - type: 'context.append_loop_event', - event: { - type: 'tool.result', - toolCallId: 'call_1', - result: { output: 'command output', isError: false }, - }, - }, - { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 'st1' } }, - ], - contextMessages: [], - }); - - const page = await svc.list('s1', {}); - - const toolMessage = page.items.find((m) => m.role === 'tool'); - expect(toolMessage?.content[0]).toEqual({ - type: 'tool_result', - tool_call_id: 'call_1', - output: 'command output', - }); - }); - - it('uses wire record times for created_at, nudged to stay strictly increasing', async () => { - const svc = buildService({ - summary, // createdAt: 1000 - records: [ - { type: 'context.append_message', message: textMessage('user', 'u1'), time: 5000 }, - // Same record time → the second is nudged one ms forward. - { type: 'context.append_message', message: textMessage('assistant', 'a1'), time: 5000 }, - // No record time → falls back to session createdAt + index, then nudged. - { type: 'context.append_message', message: textMessage('user', 'u2') }, - ], - contextMessages: [], - }); - - const page = await svc.list('s1', {}); - - // Newest first: u2 (nudged 5002), a1 (nudged 5001), u1 (5000). - const created = page.items.map((m) => new Date(m.created_at).getTime()); - expect(created).toEqual([5002, 5001, 5000]); - }); -}); diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index add4c2b6179..22ce7ca705a 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -25,7 +25,7 @@ import type { TaskOrigin, UserPromptOrigin, } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; -import { messageContentSchema } from '@moonshot-ai/agent-core-v2/agent/contextMemory/protocolMessage'; +import { messageContentSchema } from './message'; import type { HookResultEvent } from '@moonshot-ai/agent-core-v2/agent/externalHooks/externalHooksService'; import type { CompactionBlockedEvent, diff --git a/packages/agent-core-v2/src/agent/contextMemory/protocolMessage.ts b/packages/kap-server/src/protocol/message.ts similarity index 87% rename from packages/agent-core-v2/src/agent/contextMemory/protocolMessage.ts rename to packages/kap-server/src/protocol/message.ts index 141403f3982..bf1faa14a39 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/protocolMessage.ts +++ b/packages/kap-server/src/protocol/message.ts @@ -1,5 +1,9 @@ /** - * The wire `Message` shape — the legacy REST/streaming message format. + * The v1 wire `Message` shape — the legacy REST/streaming message format served + * on the `messages`, `snapshot`, and `sessions` (`:undo`) surfaces, and accepted + * on the `prompts` submission surface. Owned by kap-server: the engine speaks + * only the native `ContextMessage`, and `services/messages/messageProjection` + * projects it into this shape at the edge. * * Media sources come in three kinds: `url`, `base64`, and `file` (a daemon * upload id). The `url` kind optionally pairs an `id` — the provider-issued @@ -9,7 +13,7 @@ import { z } from 'zod'; -import { isoDateTimeSchema } from '#/_base/utils/isoDateTime'; +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; export const messageRoleSchema = z.enum(['user', 'assistant', 'tool', 'system']); export type MessageRole = z.infer; diff --git a/packages/kap-server/src/protocol/rest-message.ts b/packages/kap-server/src/protocol/rest-message.ts index a2b7bcbaf1f..d81d489250c 100644 --- a/packages/kap-server/src/protocol/rest-message.ts +++ b/packages/kap-server/src/protocol/rest-message.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; -import { messageRoleSchema, messageSchema } from '@moonshot-ai/agent-core-v2/agent/contextMemory/protocolMessage'; +import { messageRoleSchema, messageSchema } from './message'; import { cursorQuerySchema } from './pagination'; diff --git a/packages/kap-server/src/protocol/rest-prompt.ts b/packages/kap-server/src/protocol/rest-prompt.ts index 88aebc496c4..b04a2fe78af 100644 --- a/packages/kap-server/src/protocol/rest-prompt.ts +++ b/packages/kap-server/src/protocol/rest-prompt.ts @@ -20,7 +20,7 @@ import { z } from 'zod'; import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; -import { messageContentSchema } from '@moonshot-ai/agent-core-v2/agent/contextMemory/protocolMessage'; +import { messageContentSchema } from './message'; import { promptPermissionModeSchema, promptThinkingSchema, diff --git a/packages/kap-server/src/protocol/rest-session.ts b/packages/kap-server/src/protocol/rest-session.ts index 4cf33af6a76..18b04dd0769 100644 --- a/packages/kap-server/src/protocol/rest-session.ts +++ b/packages/kap-server/src/protocol/rest-session.ts @@ -17,7 +17,7 @@ import { z } from 'zod'; -import { messageSchema } from '@moonshot-ai/agent-core-v2/agent/contextMemory/protocolMessage'; +import { messageSchema } from './message'; import { sessionStatusResponseSchema, sessionWarningSchema, diff --git a/packages/kap-server/src/protocol/rest-snapshot.ts b/packages/kap-server/src/protocol/rest-snapshot.ts index 9b445d61582..35e0b4335e5 100644 --- a/packages/kap-server/src/protocol/rest-snapshot.ts +++ b/packages/kap-server/src/protocol/rest-snapshot.ts @@ -15,7 +15,7 @@ import { z } from 'zod'; -import { messageSchema } from '@moonshot-ai/agent-core-v2/agent/contextMemory/protocolMessage'; +import { messageSchema } from './message'; import { approvalRequestSchema } from './approval'; import { questionRequestSchema } from './question'; diff --git a/packages/kap-server/src/routes/messages.ts b/packages/kap-server/src/routes/messages.ts index 33339245fb7..13cb032db5a 100644 --- a/packages/kap-server/src/routes/messages.ts +++ b/packages/kap-server/src/routes/messages.ts @@ -1,12 +1,11 @@ /** - * `/sessions/{session_id}/messages*` route handlers — server-v2 port. + * `/sessions/{session_id}/messages*` route handlers. * * Implements the v1 `/api/v1/sessions/{sid}/messages` wire contract on top of - * `IMessageLegacyService` (`packages/agent-core-v2/src/messageLegacy`), which - * reads the persisted wire transcript for cold sessions and the live context - * for live ones. This route is a thin adapter: it resolves the Core-scoped - * legacy service, projects the result into the protocol envelope, and maps the - * domain error codes to the v1 wire codes. + * `services/messages/messageHistory`, which reads the persisted wire transcript + * for cold sessions and merges the unflushed live tail for live ones. This + * route is a thin adapter: it projects the result into the protocol envelope + * and maps the sentinel errors to the v1 wire codes. * * GET /sessions/{session_id}/messages query: ListMessages data: Page * GET /sessions/{session_id}/messages/{message_id} - data: Message @@ -17,15 +16,21 @@ * - invalid query → `40001` (validation.failed, via defineRoute) */ -import { IMessageLegacyService, isError2, type Scope } from '@moonshot-ai/agent-core-v2'; -import { messageRoleSchema } from '@moonshot-ai/agent-core-v2/agent/contextMemory/protocolMessage'; +import { type Scope } from '@moonshot-ai/agent-core-v2'; import { ErrorCode } from '../protocol/error-codes'; +import { messageRoleSchema } from '../protocol/message'; import { getMessageResponseSchema, listMessagesResponseSchema } from '../protocol/rest-message'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; +import { + getMessage, + listMessages, + MessageNotFoundError, + SessionNotFoundError, +} from '../services/messages/messageHistory'; interface MessageRouteHost { get( @@ -97,7 +102,7 @@ export function registerMessagesRoutes(app: MessageRouteHost, core: Scope): void async (req, reply) => { try { const { session_id } = req.params; - const page = await core.accessor.get(IMessageLegacyService).list(session_id, req.query); + const page = await listMessages(core, session_id, req.query); reply.send(okEnvelope(page, req.id)); } catch (err) { sendMappedError(reply, req, err); @@ -128,7 +133,7 @@ export function registerMessagesRoutes(app: MessageRouteHost, core: Scope): void async (req, reply) => { try { const { session_id, message_id } = req.params; - const message = await core.accessor.get(IMessageLegacyService).get(session_id, message_id); + const message = await getMessage(core, session_id, message_id); reply.send(okEnvelope(message, req.id)); } catch (err) { sendMappedError(reply, req, err); @@ -143,10 +148,10 @@ export function registerMessagesRoutes(app: MessageRouteHost, core: Scope): void } /** - * Map a thrown `Error2` to the right envelope: - * - `session.not_found` → `code: 40401` - * - `message.not_found` → `code: 40403` - * - anything else → `code: 50001`. + * Map a thrown sentinel error to the right envelope: + * - unknown session → `code: 40401` + * - unknown message → `code: 40403` + * - anything else → `code: 50001`. */ function sendMappedError( reply: { send(payload: unknown): unknown }, @@ -155,15 +160,13 @@ function sendMappedError( ): void { const requestId = req.id; const log = requestLog(req); - if (isError2(err)) { - switch (err.code) { - case 'session.not_found': - reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); - return; - case 'message.not_found': - reply.send(errEnvelope(ErrorCode.MESSAGE_NOT_FOUND, err.message, requestId, err.stack)); - return; - } + if (err instanceof SessionNotFoundError) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); + return; + } + if (err instanceof MessageNotFoundError) { + reply.send(errEnvelope(ErrorCode.MESSAGE_NOT_FOUND, err.message, requestId, err.stack)); + return; } log?.error({ err }, 'message request failed'); reply.send( diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 047d206c764..1bb705a3a29 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -27,7 +27,6 @@ import { registerFsRoutes } from './fs'; import { registerGuiStoreRoutes } from './guiStore'; import { registerMessagesRoutes } from './messages'; import type { IGuiStoreService } from '../services/guiStore/guiStore'; -import type { ISnapshotReader } from '../services/snapshot'; import { registerDebugRoutes } from '../transport/registerDebugRoutes'; import { registerMetaRoute } from './meta'; import { registerModelCatalogRoutes } from './modelCatalog'; @@ -76,7 +75,6 @@ export interface RegisterApiV1RoutesOptions { readonly onShutdown: () => void; readonly connectionRegistry: IConnectionRegistry; readonly broadcaster: SessionEventBroadcaster; - readonly snapshotReader: ISnapshotReader; readonly transcriptService: TranscriptService; /** * Surface `dangerous_bypass_auth` in the `/meta` payload. Set by `start.ts` @@ -177,7 +175,6 @@ export async function registerApiV1Routes( registerSnapshotRoutes(apiV1 as unknown as Parameters[0], { core, broadcaster: opts.broadcaster, - reader: opts.snapshotReader, }); registerTranscriptRoutes(apiV1 as unknown as Parameters[0], { core, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index ce666c946cb..70e8ec95c08 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -100,13 +100,13 @@ import { resumeSessionById, isError2, Error2, - toProtocolMessage, type ContextMessage, type IAgentScopeHandle, type Scope, } from '@moonshot-ai/agent-core-v2'; import { ErrorCode } from '../protocol/error-codes'; import { pageResponseSchema } from '../protocol/pagination'; +import { toProtocolMessage } from '../services/messages/messageProjection'; import { archiveSessionResponseSchema, compactSessionRequestSchema, diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index 929b06caa08..9ff7cfa0a45 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -1,54 +1,38 @@ /** - * `GET /sessions/{session_id}/snapshot` — IM-style initial sync. + * `GET /sessions/{session_id}/snapshot` — atomic session snapshot for client + * rebuild: state + `as_of_seq` watermark + `epoch`, assembled from the engine + * services. Cold sessions are resumed through `ISessionLifecycleService.resume` + * — the same path `messages` and `:undo` use — and the message page comes from + * the shared full-transcript loader (`services/messages/messageHistory`), so + * this endpoint and `GET /sessions/{sid}/messages` serve the same history: + * full across compactions, media rehydrated. * - * **Reader strategy** (controlled by `KIMI_SNAPSHOT_READER`): - * - * - `auto` (default) — delegate to `ISnapshotReader`, which reads - * `state.json` + `agents/main/wire.jsonl` directly from disk and bypasses - * the heavy session-resume chain (handler + DI scope materialization, MCP - * connect, full wire replay). Sub-200ms warm / sub-1s cold. - * - `legacy` — fall back to `resume` + live service assembly. Pure operator - * escape hatch; no silent per-request fallback. - * - * **Timeout**: the auto path races against a hard `KIMI_SNAPSHOT_TIMEOUT_MS` - * ceiling (default 4000ms, under traefik's 5s cut-off). Timeout returns 50001 - * with a structured `snapshot.timeout` log line so the gateway never sees a 499. - * - * **Error mapping**: `SnapshotNotFoundError` → 40401; `SnapshotTimeoutError` → - * 50001; everything else falls through to the global error handler (→ 50001). + * **Error mapping**: `SnapshotNotFoundError` → 40401; everything else falls + * through to the global error handler (→ 50001). */ import { - IAgentContextMemoryService, - IAgentLifecycleService, + ensureMainAgent, IAgentPromptService, - ILogService, - ISessionInteractionService, ISessionContext, + ISessionInteractionService, ISessionMetadata, IWorkspaceService, resumeSessionById, - toProtocolMessage, type IAgentScopeHandle, type Scope, } from '@moonshot-ai/agent-core-v2'; -import type { Message } from '@moonshot-ai/agent-core-v2/agent/contextMemory/protocolMessage'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; import { ErrorCode } from '../protocol/error-codes'; import { sessionSnapshotResponseSchema, type InFlightTurn, type SessionSnapshotResponse, } from '../protocol/rest-snapshot'; -import { z } from 'zod'; - -import { errEnvelope, okEnvelope } from '../envelope'; -import { defineRoute } from '../middleware/defineRoute'; -import { - SnapshotNotFoundError, - SnapshotTimeoutError, - loadSnapshotConfig, -} from '../services/snapshot'; -import type { ISnapshotReader } from '../services/snapshot'; +import { loadMessageHistory } from '../services/messages/messageHistory'; import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster'; import { toWireApproval } from './approvals'; import { toWireQuestion } from './questions'; @@ -57,6 +41,14 @@ import { resolveSessionFacts, toWireSession } from './sessions'; /** Most-recent messages included in the snapshot page. */ const SNAPSHOT_MESSAGE_PAGE_SIZE = 100; +/** Sentinel — the handler maps it to 40401. */ +class SnapshotNotFoundError extends Error { + constructor(sessionId: string) { + super(`session ${sessionId} does not exist`); + this.name = 'SnapshotNotFoundError'; + } +} + const sessionIdParamSchema = z.object({ session_id: z.string().min(1), }); @@ -75,13 +67,10 @@ interface SnapshotRouteHost { export interface SnapshotRouteDeps { readonly core: Scope; readonly broadcaster: SessionEventBroadcaster; - readonly reader: ISnapshotReader; } export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRouteDeps): void { - const { core, broadcaster, reader } = deps; - const config = loadSnapshotConfig(); - const useReader = config.mode !== 'legacy'; + const { core, broadcaster } = deps; const route = defineRoute( { @@ -100,22 +89,13 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou async (req, reply) => { const { session_id } = req.params; try { - const data = useReader - ? await readViaReader(reader, session_id, config.timeoutMs) - : await readViaLegacyAssembly(core, broadcaster, session_id); + const data = await assembleSnapshot(core, broadcaster, session_id); reply.send(okEnvelope(data, req.id)); } catch (err) { if (err instanceof SnapshotNotFoundError) { reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, req.id, err.stack)); return; } - if (err instanceof SnapshotTimeoutError) { - core.accessor - .get(ILogService) - .warn('snapshot.timeout', { sid: session_id, duration_ms: err.timeoutMs }); - reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, req.id, err.stack)); - return; - } throw err; } }, @@ -123,24 +103,7 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou app.get(route.path, route.options, route.handler as Parameters[2]); } -async function readViaReader( - reader: ISnapshotReader, - sid: string, - timeoutMs: number, -): Promise { - let timer: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new SnapshotTimeoutError(sid, timeoutMs)), timeoutMs); - timer.unref?.(); - }); - try { - return await Promise.race([reader.read(sid), timeoutPromise]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -async function readViaLegacyAssembly( +async function assembleSnapshot( core: Scope, broadcaster: SessionEventBroadcaster, sessionId: string, @@ -170,19 +133,14 @@ async function readViaLegacyAssembly( resolveSessionFacts(core, sessionId), ); - // Messages — most recent page of the main agent's live history. - const main = handle.accessor.get(IAgentLifecycleService).get('main'); - let items: Message[] = []; - let hasMore = false; - if (main !== undefined) { - const history = main.accessor.get(IAgentContextMemoryService).get(); - hasMore = history.length > SNAPSHOT_MESSAGE_PAGE_SIZE; - const page = history.slice(-SNAPSHOT_MESSAGE_PAGE_SIZE); - const offset = history.length - page.length; - items = page.map((msg, i) => toProtocolMessage(sessionId, offset + i, msg, meta.createdAt)); - } - const currentPromptId = - snapState.inFlightTurn === null ? undefined : readCurrentPromptId(main); + // Messages — most recent page of the main agent's full history, from the + // loader shared with the `messages` routes. + const main = await ensureMainAgent(handle); + const all = await loadMessageHistory(core, main, sessionId, meta.createdAt); + const hasMore = all.length > SNAPSHOT_MESSAGE_PAGE_SIZE; + const items = all.slice(-SNAPSHOT_MESSAGE_PAGE_SIZE); + + const currentPromptId = snapState.inFlightTurn === null ? undefined : readCurrentPromptId(main); const inFlightTurn = attachCurrentPromptIdToInFlight(snapState.inFlightTurn, currentPromptId); // Pending approvals / questions. diff --git a/packages/kap-server/src/services/messages/messageHistory.ts b/packages/kap-server/src/services/messages/messageHistory.ts new file mode 100644 index 00000000000..e32d2128311 --- /dev/null +++ b/packages/kap-server/src/services/messages/messageHistory.ts @@ -0,0 +1,216 @@ +/** + * v1-compatible message history — the loader behind + * `GET /api/v1/sessions/{sid}/messages[/{mid}]`, served from the server layer + * on top of the engine's native services (moved out of the engine's deleted + * `messageLegacy` edge adapter). + * + * History is streamed from the main agent's append log after its pending wire + * writes are flushed. The journal is folded incrementally by the shared + * transcript reducer, keeping full history across compactions (inserting a + * summary marker instead of folding) — unlike the live + * `IAgentContextMemoryService.get()`, whose folded context collapses into + * `[...keptUserMessages, compaction_summary]` and would lose the prefix. + * `foldedLength` is what the live history length WOULD be from the journal's + * records; because the journal can trail the live context by a record within a + * single dispatch, anything beyond it is appended as the unflushed tail. + * Pagination, id derivation, and the role filter mirror the legacy v1 + * semantics. + */ + +import { + AGENT_WIRE_RECORD_KEY, + IAgentBlobService, + IAgentContextMemoryService, + IAgentScopeContext, + IAppendLogStore, + ISessionIndex, + IWireService, + createContextTranscriptReducer, + ensureMainAgent, + resumeSessionById, + type ContextMessage, + type ContextTranscript, + type IAgentScopeHandle, + type Scope, + type WireRecord, +} from '@moonshot-ai/agent-core-v2'; + +import type { Message, MessageRole } from '../../protocol/message'; +import { toProtocolMessage } from './messageProjection'; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; + +/** Sentinel — the route maps it to 40401. */ +export class SessionNotFoundError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super(`session ${sessionId} does not exist`); + this.name = 'SessionNotFoundError'; + this.sessionId = sessionId; + } +} + +/** Sentinel — the route maps it to 40403. */ +export class MessageNotFoundError extends Error { + readonly sessionId: string; + readonly messageId: string; + constructor(sessionId: string, messageId: string) { + super(`message ${messageId} does not exist in session ${sessionId}`); + this.name = 'MessageNotFoundError'; + this.sessionId = sessionId; + this.messageId = messageId; + } +} + +export interface MessageListQuery { + readonly before_id?: string | undefined; + readonly after_id?: string | undefined; + readonly page_size?: number | undefined; + readonly role?: MessageRole | undefined; +} + +export interface PageResponse { + items: T[]; + has_more: boolean; +} + +export async function listMessages( + core: Scope, + sessionId: string, + query: MessageListQuery, +): Promise> { + const all = await loadMessages(core, sessionId); + const desc = [...all].reverse(); + + let pivotIndex = -1; + if (query.before_id !== undefined) { + pivotIndex = desc.findIndex((m) => m.id === query.before_id); + } else if (query.after_id !== undefined) { + pivotIndex = desc.findIndex((m) => m.id === query.after_id); + } + + let slice: Message[]; + if (query.before_id !== undefined && pivotIndex >= 0) { + slice = desc.slice(pivotIndex + 1); + } else if (query.after_id !== undefined && pivotIndex >= 0) { + slice = desc.slice(0, pivotIndex); + } else { + slice = desc; + } + + const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; + const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); + const page = slice.slice(0, pageSize); + const hasMore = slice.length > pageSize; + + const filtered = query.role !== undefined ? page.filter((m) => m.role === query.role) : page; + + return { items: filtered, has_more: hasMore }; +} + +export async function getMessage( + core: Scope, + sessionId: string, + messageId: string, +): Promise { + const all = await loadMessages(core, sessionId); + const entry = all.find((m) => m.id === messageId); + if (entry === undefined) { + throw new MessageNotFoundError(sessionId, messageId); + } + return entry; +} + +async function loadMessages(core: Scope, sessionId: string): Promise { + const summary = await core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) { + throw new SessionNotFoundError(sessionId); + } + + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) return []; + const agent = await ensureMainAgent(session); + + return loadMessageHistory(core, agent, sessionId, summary.createdAt); +} + +/** + * One agent's full, ascending, projected message history: the persisted + * journal (flushed first) folded by the transcript reducer, the unflushed + * live tail merged in, blob references rehydrated, and timestamps clamped + * strictly increasing. Shared by the `messages` routes and the `snapshot` + * route so all history-serving surfaces agree. + */ +export async function loadMessageHistory( + core: Scope, + agent: IAgentScopeHandle, + sessionId: string, + sessionCreatedAtMs: number, +): Promise { + const transcript = await readTranscript(core, agent); + const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); + const merged = mergeLiveTail(transcript, contextMessages); + const entries = await rehydrate(agent, merged.messages); + + let previousMs = Number.NEGATIVE_INFINITY; + return entries.map((msg, index) => { + const baseMs = merged.times[index] ?? sessionCreatedAtMs + index; + const createdAtMs = Math.max(previousMs + 1, baseMs); + previousMs = createdAtMs; + return toProtocolMessage(sessionId, index, msg, sessionCreatedAtMs, createdAtMs); + }); +} + +/** + * Replace `blobref:` media URLs with `data:` URIs read from the agent's + * blob store (v1's `rehydrateBlobRefs`); unresolvable refs become the + * `[media missing]` placeholder, same as v1 and live replay. + */ +async function rehydrate( + agent: IAgentScopeHandle, + messages: readonly ContextMessage[], +): Promise { + const blobs = agent.accessor.get(IAgentBlobService); + let changed = false; + const out: ContextMessage[] = []; + for (const msg of messages) { + const content = await blobs.loadParts(msg.content); + if (content === msg.content) { + out.push(msg); + continue; + } + changed = true; + out.push({ ...msg, content: [...content] }); + } + return changed ? out : messages; +} + +async function readTranscript(core: Scope, agent: IAgentScopeHandle): Promise { + await agent.accessor.get(IWireService).flush(); + const scope = agent.accessor.get(IAgentScopeContext).scope(); + const reducer = createContextTranscriptReducer(); + for await (const record of core.accessor + .get(IAppendLogStore) + .read(scope, AGENT_WIRE_RECORD_KEY)) { + reducer.add(record); + } + return reducer.result(); +} + +function mergeLiveTail( + transcript: ContextTranscript, + contextMessages: readonly ContextMessage[], +): { + readonly messages: readonly ContextMessage[]; + readonly times: readonly (number | undefined)[]; +} { + if (contextMessages.length <= transcript.foldedLength) { + return { messages: transcript.entries, times: transcript.times }; + } + const tail = contextMessages.slice(transcript.foldedLength); + return { + messages: [...transcript.entries, ...tail], + times: [...transcript.times, ...tail.map(() => undefined)], + }; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts b/packages/kap-server/src/services/messages/messageProjection.ts similarity index 90% rename from packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts rename to packages/kap-server/src/services/messages/messageProjection.ts index a9d4d72885e..ad846f01ee9 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts +++ b/packages/kap-server/src/services/messages/messageProjection.ts @@ -1,8 +1,10 @@ /** - * `contextMemory` protocol projection — `ContextMessage` → wire `Message`. + * `ContextMessage` → v1 wire `Message` projection. * - * Mirrors the v1 protocol projection so the legacy REST/streaming message - * objects stay byte-compatible. + * Mirrors the v1 protocol projection so the `messages`, `snapshot`, and + * `sessions` (`:undo`) surfaces produce byte-compatible message objects. + * Lives in kap-server (next to the wire schema in `protocol/message.ts`) — + * the engine speaks only the native `ContextMessage`. * * Tool results project to a single `tool_result` part: plain-text results keep * the historical flattened-text output, while a result carrying media parts @@ -18,10 +20,9 @@ * marker. */ -import type { Message, MessageContent, MessageRole, ToolUseContent } from './protocolMessage'; +import { parseKimiFileUrl, type ContextMessage } from '@moonshot-ai/agent-core-v2'; -import { parseKimiFileUrl } from '#/agent/media/kimiFileUrl'; -import type { ContextMessage } from './types'; +import type { Message, MessageContent, MessageRole, ToolUseContent } from '../../protocol/message'; function deriveMessageId(sessionId: string, index: number): string { const padded = String(index).padStart(6, '0'); diff --git a/packages/kap-server/src/services/snapshot/index.ts b/packages/kap-server/src/services/snapshot/index.ts deleted file mode 100644 index 8447bc4c3ba..00000000000 --- a/packages/kap-server/src/services/snapshot/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { ISnapshotReader } from './snapshot'; -export { SnapshotNotFoundError, SnapshotTimeoutError } from './snapshot'; -export { - SnapshotReader, - readWireRecords, - type SnapshotReaderDeps, - type SnapshotReaderLogger, -} from './snapshotReader'; -export { loadSnapshotConfig } from './snapshotConfig'; -export type { SnapshotConfig, SnapshotReaderMode } from './snapshotConfig'; diff --git a/packages/kap-server/src/services/snapshot/snapshot.ts b/packages/kap-server/src/services/snapshot/snapshot.ts deleted file mode 100644 index 9e68c18cddd..00000000000 --- a/packages/kap-server/src/services/snapshot/snapshot.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * `ISnapshotReader` — server-layer disk reader backing - * `GET /sessions/{sid}/snapshot` in `auto` mode. - * - * Reads `state.json` + `agents/main/wire.jsonl` directly from disk, bypassing - * the session-resume chain (handler + DI-scope materialization, MCP - * connect, full wire replay). Mirrors v1's `ISnapshotService` - * (`packages/server/src/services/snapshot/snapshot.ts`). - */ - -import type { SessionSnapshotResponse } from '../../protocol/rest-snapshot'; - -export interface ISnapshotReader { - /** Assemble the atomic snapshot for `sid`. Throws `SnapshotNotFoundError` when the session (or its workspace) is absent on disk. */ - read(sid: string): Promise; -} - -/** Sentinel — route maps to 40401. */ -export class SnapshotNotFoundError extends Error { - readonly sessionId: string; - constructor(sessionId: string) { - super(`session ${sessionId} does not exist`); - this.name = 'SnapshotNotFoundError'; - this.sessionId = sessionId; - } -} - -/** Sentinel — route maps to 50001 with a structured `snapshot.timeout` log. */ -export class SnapshotTimeoutError extends Error { - readonly sessionId: string; - readonly timeoutMs: number; - constructor(sessionId: string, timeoutMs: number) { - super(`snapshot ${sessionId} timed out after ${timeoutMs}ms`); - this.name = 'SnapshotTimeoutError'; - this.sessionId = sessionId; - this.timeoutMs = timeoutMs; - } -} diff --git a/packages/kap-server/src/services/snapshot/snapshotConfig.ts b/packages/kap-server/src/services/snapshot/snapshotConfig.ts deleted file mode 100644 index 20a56ca14b1..00000000000 --- a/packages/kap-server/src/services/snapshot/snapshotConfig.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Env-driven knobs for the snapshot read path. Read once at route registration. - * - * Mirrors v1 (`packages/server/src/services/snapshot/snapshotConfig.ts`): - * - * KIMI_SNAPSHOT_READER 'auto' (default) | 'legacy' - * KIMI_SNAPSHOT_TIMEOUT_MS integer ms hard ceiling on the auto path (default 4000) - * KIMI_SNAPSHOT_CACHE_LIMIT transcript LRU entries (default 32) - */ - -export type SnapshotReaderMode = 'auto' | 'legacy'; - -export interface SnapshotConfig { - readonly mode: SnapshotReaderMode; - readonly timeoutMs: number; - readonly cacheLimit: number; -} - -const DEFAULT_TIMEOUT_MS = 4000; -const DEFAULT_CACHE_LIMIT = 32; - -function parseInteger(value: string | undefined, fallback: number, min: number): number { - if (value === undefined) return fallback; - const n = Number.parseInt(value, 10); - if (!Number.isFinite(n) || n < min) return fallback; - return n; -} - -export function loadSnapshotConfig(env: NodeJS.ProcessEnv = process.env): SnapshotConfig { - const rawMode = env['KIMI_SNAPSHOT_READER']?.trim().toLowerCase(); - const mode: SnapshotReaderMode = rawMode === 'legacy' ? 'legacy' : 'auto'; - return { - mode, - timeoutMs: parseInteger(env['KIMI_SNAPSHOT_TIMEOUT_MS'], DEFAULT_TIMEOUT_MS, 100), - cacheLimit: parseInteger(env['KIMI_SNAPSHOT_CACHE_LIMIT'], DEFAULT_CACHE_LIMIT, 1), - }; -} diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts deleted file mode 100644 index f5ff963e76d..00000000000 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * `SnapshotReader` — server-layer disk reader for `GET /sessions/{sid}/snapshot` - * (`KIMI_SNAPSHOT_READER=auto`, the default). - * - * Reads `/sessions///state.json` and - * `…/agents/main/wire.jsonl` directly, bypassing the session-resume chain - * (handler materialization, DI-scope materialization, MCP connect, - * full wire replay). The transcript is reduced from the `context.*` records - * with `reduceContextTranscript`, which mirrors the live reducers EXCEPT that - * `context.apply_compaction` keeps the full history and appends a summary - * marker instead of dropping the compacted prefix — the same full-transcript - * view v1 serves (so compacted-away assistant replies stay visible after a - * later undo). `(size, mtimeMs)` transcript cache and the watermark both come - * from in-memory state, keeping warm reads sub-ms. - * - * Pending approvals/questions, the live status, and `current_prompt_id` are - * only available while the session is live; for a cold session they correctly - * resolve to empty / `'idle'` (a cold session owns no runtime interaction). - */ - -import { readFile, stat as fsStat } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { - IAgentLifecycleService, - IAgentPromptService, - ISessionIndex, - ISessionInteractionService, - IWorkspaceService, - getLiveSessionById, - normalizeSessionMeta, - reduceContextTranscript, - toProtocolMessage, - type ContextMessage, - type ISessionScopeHandle, - type Scope, - type SessionMeta, -} from '@moonshot-ai/agent-core-v2'; - -import { toWireApproval } from '../../routes/approvals'; -import { toWireQuestion } from '../../routes/questions'; -import { resolveSessionFacts, toWireSession } from '../../routes/sessions'; -import { type SessionEventBroadcaster } from '../../transport/ws/v1/sessionEventBroadcaster'; -import type { InFlightTurn, SessionSnapshotResponse } from '../../protocol/rest-snapshot'; -import { SnapshotNotFoundError } from './snapshot'; -import type { ISnapshotReader } from './snapshot'; -import { type SnapshotConfig } from './snapshotConfig'; - -const SESSIONS_ROOT = 'sessions'; -const AGENTS_DIR = 'agents'; -const BLOBS_DIR = 'blobs'; -const MAIN_AGENT_ID = 'main'; -const STATE_FILE = 'state.json'; -const WIRE_FILE = 'wire.jsonl'; -const SNAPSHOT_MESSAGE_PAGE_SIZE = 100; -const BLOBREF_PROTOCOL = 'blobref:'; -const MISSING_MEDIA_PLACEHOLDER = '[media missing]'; - -export interface SnapshotReaderLogger { - info(obj: Record, msg: string): void; -} - -export interface SnapshotReaderDeps { - readonly homeDir: string; - readonly core: Scope; - readonly broadcaster: SessionEventBroadcaster; - readonly logger: SnapshotReaderLogger; - readonly config: SnapshotConfig; -} - -interface TranscriptCacheEntry { - readonly size: number; - readonly mtimeMs: number; - readonly messages: ContextMessage[]; - readonly times: readonly (number | undefined)[]; -} - -interface LocatedSession { - readonly workspaceId: string; - readonly cwd: string; - readonly sessionDir: string; - readonly meta: SessionMeta; -} - -export class SnapshotReader implements ISnapshotReader { - private readonly transcriptCache = new Map(); - - constructor(private readonly deps: SnapshotReaderDeps) {} - - async read(sid: string): Promise { - const startMs = Date.now(); - const { core, broadcaster, logger } = this.deps; - - const located = await this.locateSession(sid); - - const [snapState, transcript] = await Promise.all([ - broadcaster.getSnapshotState(sid), - this.readTranscriptCached(sid, located.sessionDir), - ]); - - const full = transcript.messages; - const hasMore = full.length > SNAPSHOT_MESSAGE_PAGE_SIZE; - const offset = hasMore ? full.length - SNAPSHOT_MESSAGE_PAGE_SIZE : 0; - const page = hasMore ? full.slice(offset) : full; - await this.rehydrateBlobRefs(page, join(located.sessionDir, AGENTS_DIR, MAIN_AGENT_ID, BLOBS_DIR)); - // `created_at` prefers the real per-record time stamped onto wire.jsonl at - // dispatch; records predating the stamp (or the `metadata` envelope) fall - // back to the synthesized `session.createdAt + index`, clamped so the - // page stays strictly increasing (mirrors `MessageLegacyService.list`). - let previousMs = Number.NEGATIVE_INFINITY; - const items = page.map((msg, i) => { - const index = offset + i; - const baseMs = transcript.times[index] ?? located.meta.createdAt + index; - const createdAtMs = Math.max(previousMs + 1, baseMs); - previousMs = createdAtMs; - return toProtocolMessage(sid, index, msg, located.meta.createdAt, createdAtMs); - }); - - const live = getLiveSessionById(core.accessor, sid); - const session = toWireSession( - { ...located.meta, workspaceId: located.workspaceId }, - located.cwd, - resolveSessionFacts(core, sid), - ); - - const inFlightTurn = this.attachCurrentPromptId(sid, live, snapState.inFlightTurn); - const { approvals, questions } = this.readPending(sid, live); - - logger.info( - { - sid, - duration_ms: Date.now() - startMs, - cache: transcript.tag, - transcript_entries: full.length, - wire_bytes: transcript.wireBytes, - }, - 'snapshot.read', - ); - - return { - as_of_seq: snapState.seq, - epoch: snapState.epoch, - session, - messages: { items, has_more: hasMore }, - in_flight_turn: inFlightTurn, - subagents: snapState.subagents, - pending_approvals: approvals, - pending_questions: questions, - }; - } - - /** - * Resolve `(workspaceId, sessionDir, cwd, meta)` for `sid`. Mirrors the - * legacy route's 404 conditions: unknown to the index, or workspace no longer - * registered (cwd is unrecoverable and would produce an invalid `Session`). - */ - private async locateSession(sid: string): Promise { - const { core, homeDir } = this.deps; - const summary = await core.accessor.get(ISessionIndex).get(sid); - if (summary === undefined) throw new SnapshotNotFoundError(sid); - const workspace = await core.accessor.get(IWorkspaceService).get(summary.workspaceId); - if (workspace === undefined) throw new SnapshotNotFoundError(sid); - - const sessionDir = join(homeDir, SESSIONS_ROOT, summary.workspaceId, sid); - const rawMeta = await this.readStateMeta(join(sessionDir, STATE_FILE)); - const meta = normalizeSessionMeta((rawMeta ?? summary) as SessionMeta, sid); - return { workspaceId: summary.workspaceId, cwd: workspace.root, sessionDir, meta }; - } - - /** Best-effort `state.json` read; missing / corrupt degrades to `undefined`. */ - private async readStateMeta(statePath: string): Promise { - try { - const raw = await readFile(statePath, 'utf-8'); - const parsed = JSON.parse(raw) as unknown; - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return undefined; - } - return parsed as SessionMeta; - } catch { - return undefined; - } - } - - private async readTranscriptCached( - sid: string, - sessionDir: string, - ): Promise<{ - messages: ContextMessage[]; - times: readonly (number | undefined)[]; - tag: 'hit' | 'miss' | 'shrink_invalidate' | 'enoent'; - wireBytes: number; - }> { - const wirePath = join(sessionDir, AGENTS_DIR, MAIN_AGENT_ID, WIRE_FILE); - let info: { size: number; mtimeMs: number } | undefined; - try { - info = await fsStat(wirePath); - } catch { - info = undefined; - } - if (info === undefined) { - this.transcriptCache.delete(sid); - return { messages: [], times: [], tag: 'enoent', wireBytes: 0 }; - } - - const cached = this.transcriptCache.get(sid); - if (cached !== undefined && cached.size === info.size && cached.mtimeMs === info.mtimeMs) { - // LRU touch. - this.transcriptCache.delete(sid); - this.transcriptCache.set(sid, cached); - return { messages: cached.messages, times: cached.times, tag: 'hit', wireBytes: info.size }; - } - - const tag: 'miss' | 'shrink_invalidate' = - cached !== undefined && info.size < cached.size ? 'shrink_invalidate' : 'miss'; - if (cached !== undefined) this.transcriptCache.delete(sid); - - const records = await readWireRecords(wirePath); - const { entries, times } = reduceContextTranscript(records); - const messages = [...entries]; - this.transcriptCache.set(sid, { size: info.size, mtimeMs: info.mtimeMs, messages, times }); - while (this.transcriptCache.size > this.deps.config.cacheLimit) { - const oldest = this.transcriptCache.keys().next().value; - if (oldest === undefined) break; - this.transcriptCache.delete(oldest); - } - return { messages, times, tag, wireBytes: info.size }; - } - - private attachCurrentPromptId( - sid: string, - live: ISessionScopeHandle | undefined, - inFlightTurn: InFlightTurn | null, - ): InFlightTurn | null { - if (inFlightTurn === null || live === undefined) return inFlightTurn; - const main = live.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID); - if (main === undefined) return inFlightTurn; - let currentPromptId: string | undefined; - try { - currentPromptId = main.accessor.get(IAgentPromptService).list().active?.id; - } catch { - return inFlightTurn; - } - if (currentPromptId === undefined) return inFlightTurn; - return { ...inFlightTurn, current_prompt_id: currentPromptId }; - } - - private readPending( - sid: string, - live: ISessionScopeHandle | undefined, - ): { approvals: ReturnType[]; questions: ReturnType[] } { - if (live === undefined) return { approvals: [], questions: [] }; - const interaction = live.accessor.get(ISessionInteractionService); - return { - approvals: interaction.listPending('approval').map((i) => toWireApproval(i, sid)), - questions: interaction.listPending('question').map((i) => toWireQuestion(i, sid)), - }; - } - - /** Rehydrate `blobref:;` media URLs from `/blobs/`. Mirrors v1; unresolvable refs become `[media missing]`. */ - private async rehydrateBlobRefs(messages: readonly ContextMessage[], blobsDir: string): Promise { - const cache = new Map(); - for (const message of messages) { - for (const part of message.content) { - for (const value of Object.values(part as unknown as Record)) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) continue; - const media = value as { url?: unknown }; - if (typeof media.url !== 'string' || !media.url.startsWith(BLOBREF_PROTOCOL)) continue; - media.url = (await resolveBlobRef(media.url, blobsDir, cache)) ?? MISSING_MEDIA_PLACEHOLDER; - } - } - } - } -} - -// --------------------------------------------------------------------------- -// Pure reduction + parsing helpers -// --------------------------------------------------------------------------- - -interface ContextRecord { - readonly type: string; - readonly [key: string]: unknown; -} - -/** - * Parse a `wire.jsonl` file. A torn final line (crash mid-flush) is dropped; - * corruption anywhere else throws so the route surfaces 50001. The leading - * `metadata` envelope and any non-`context.*` record are returned as-is and - * filtered by the reducer's `default` branch. - */ -export async function readWireRecords(wirePath: string): Promise { - const raw = await readFile(wirePath, 'utf8'); - const lines = raw.split('\n'); - const records: ContextRecord[] = []; - for (let i = 0; i < lines.length; i++) { - let line = lines[i]!; - if (line.endsWith('\r')) line = line.slice(0, -1); - if (line.length === 0) continue; - try { - records.push(JSON.parse(line) as ContextRecord); - } catch (parseError) { - if (i === lines.length - 1) break; - throw new Error( - `wire.jsonl: corrupted line ${i + 1} in ${wirePath}: ${String(parseError)}`, - { cause: parseError }, - ); - } - } - return records; -} - -async function resolveBlobRef( - url: string, - blobsDir: string, - cache: Map, -): Promise { - if (cache.has(url)) return cache.get(url); - let resolved: string | undefined; - const rest = url.slice(BLOBREF_PROTOCOL.length); - const semiIdx = rest.indexOf(';'); - if (semiIdx !== -1) { - const mimeType = rest.slice(0, semiIdx); - const hash = rest.slice(semiIdx + 1); - if (/^[0-9a-f]{16,}$/i.test(hash)) { - const payload = await readFile(join(blobsDir, hash)).catch(() => undefined); - if (payload !== undefined) { - resolved = `data:${mimeType};base64,${payload.toString('base64')}`; - } - } - } - cache.set(url, resolved); - return resolved; -} diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index a7165294a2a..581821f2305 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -22,12 +22,11 @@ * reset target. * * Cold path: rebuilds one agent's transcript from the persisted wire records - * (`/agents//wire.jsonl`), exactly the - * `SnapshotReader` read (`readWireRecords` + `reduceContextTranscript`), then - * groups the flat messages into a base snapshot via - * `groupMessagesIntoSnapshot` and folds the non-`context.*` records - * (tasks / interactions / todos / goal / plan / swarm) on top via - * `foldWireRecordFacts` — best-effort fidelity. + * (`/agents//wire.jsonl`, parsed by `readWireRecords` and + * folded by `reduceContextTranscript`), then groups the flat messages into a + * base snapshot via `groupMessagesIntoSnapshot` and folds the + * non-`context.*` records (tasks / interactions / todos / goal / plan / + * swarm) on top via `foldWireRecordFacts` — best-effort fidelity. * * Lifecycle: entries are dropped when the session closes or archives * (`onDidCloseSession` / `onDidArchiveSession`, plus a lifecycle re-check on @@ -71,7 +70,7 @@ import { type TranscriptTurn, } from '@moonshot-ai/transcript'; -import { readWireRecords } from '../snapshot/snapshotReader'; +import { readWireRecords } from './wireRecords'; import { bindSessionTranscript, descriptorFromMeta, diff --git a/packages/kap-server/src/services/transcript/wireRecords.ts b/packages/kap-server/src/services/transcript/wireRecords.ts new file mode 100644 index 00000000000..e8ec72ed64e --- /dev/null +++ b/packages/kap-server/src/services/transcript/wireRecords.ts @@ -0,0 +1,36 @@ +/** + * `readWireRecords` — parse a session agent's `wire.jsonl` journal. + * + * A torn final line (crash mid-flush) is dropped; corruption anywhere else + * throws so the caller surfaces 50001. The leading `metadata` envelope and any + * non-`context.*` record are returned as-is and filtered by the transcript + * reducer's `default` branch. + */ + +import { readFile } from 'node:fs/promises'; + +export interface ContextRecord { + readonly type: string; + readonly [key: string]: unknown; +} + +export async function readWireRecords(wirePath: string): Promise { + const raw = await readFile(wirePath, 'utf8'); + const lines = raw.split('\n'); + const records: ContextRecord[] = []; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]!; + if (line.endsWith('\r')) line = line.slice(0, -1); + if (line.length === 0) continue; + try { + records.push(JSON.parse(line) as ContextRecord); + } catch (parseError) { + if (i === lines.length - 1) break; + throw new Error( + `wire.jsonl: corrupted line ${i + 1} in ${wirePath}: ${String(parseError)}`, + { cause: parseError }, + ); + } + } + return records; +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 7e92e93ca78..fcf986dbdc3 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -62,7 +62,6 @@ import { createOriginHook, isOriginAllowed, parseCorsOrigins } from './middlewar import { createSecurityHeadersHook } from './middleware/securityHeaders'; import { createAuthHook } from './middleware/auth'; import { GuiStoreService } from './services/guiStore/guiStoreService'; -import { loadSnapshotConfig, SnapshotReader } from './services/snapshot'; import { initializeServerTelemetry, type ServerTelemetry, @@ -387,14 +386,6 @@ export async function startServer(opts: ServerStartOptions): Promise { const { default: swagger } = await import('@fastify/swagger'); await app.register(swagger, { @@ -450,7 +441,6 @@ export async function startServer(opts: ServerStartOptions): Promise { + it('maps text/think/image/audio/video content parts', () => { + const msg: ContextMessage = { + role: 'user', + content: [ + { type: 'text', text: 'hello' }, + { type: 'think', think: 'hmm', encrypted: 'sig-1' }, + { type: 'image_url', imageUrl: { url: 'https://example.com/a.png' } }, + { type: 'audio_url', audioUrl: { url: 'https://example.com/a.mp3' } }, + { type: 'video_url', videoUrl: { url: 'https://example.com/a.mp4' } }, + ], + toolCalls: [], + }; + + expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'thinking', thinking: 'hmm', signature: 'sig-1' }, + { type: 'image', source: { kind: 'url', url: 'https://example.com/a.png' } }, + { type: 'text', text: '[audio:https://example.com/a.mp3]' }, + { type: 'video', source: { kind: 'url', url: 'https://example.com/a.mp4' } }, + ]); + }); + + it('projects a kimi-file video reference to a structured file source without leaking the path', () => { + const msg: ContextMessage = { + role: 'user', + content: [ + { type: 'video_url', videoUrl: { url: 'kimi-file://file_9?path=%2Fcache%2Fclip.mp4' } }, + ], + toolCalls: [], + }; + + expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([ + { type: 'video', source: { kind: 'file', file_id: 'file_9' } }, + ]); + }); + + it('projects a provider video url to a structured url source carrying its id', () => { + const msg: ContextMessage = { + role: 'user', + content: [{ type: 'video_url', videoUrl: { url: 'ms://prov-7', id: 'prov-7' } }], + toolCalls: [], + }; + + expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([ + { type: 'video', source: { kind: 'url', url: 'ms://prov-7', id: 'prov-7' } }, + ]); + }); + + it('appends assistant tool calls as tool_use parts with parsed input', () => { + const msg: ContextMessage = { + role: 'assistant', + content: [{ type: 'text', text: 'running' }], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }, + { type: 'function', id: 'call_2', name: 'Broken', arguments: '{not json' }, + ], + }; + + expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([ + { type: 'text', text: 'running' }, + { type: 'tool_use', tool_call_id: 'call_1', tool_name: 'Bash', input: { cmd: 'ls' } }, + { type: 'tool_use', tool_call_id: 'call_2', tool_name: 'Broken', input: '{not json' }, + ]); + }); + + it('flattens a plain-text tool result into the tool_result output', () => { + const result: ContextMessage = { + role: 'tool', + content: [{ type: 'text', text: 'image result' }], + toolCalls: [], + toolCallId: 'call_image', + note: 'Image compressed.', + }; + + expect(toProtocolMessage(SESSION_ID, 0, result, 0).content).toEqual([ + { type: 'tool_result', tool_call_id: 'call_image', output: 'image result' }, + ]); + }); + + it('passes raw media parts through as the tool_result output', () => { + const result: ContextMessage = { + role: 'tool', + content: [ + { type: 'text', text: 'image result' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + ], + toolCalls: [], + toolCallId: 'call_media', + }; + + expect(toProtocolMessage(SESSION_ID, 0, result, 0).content).toEqual([ + { type: 'tool_result', tool_call_id: 'call_media', output: result.content }, + ]); + }); + + it('marks failed tool results with is_error', () => { + const result: ContextMessage = { + role: 'tool', + content: [{ type: 'text', text: 'boom' }], + toolCalls: [], + toolCallId: 'call_err', + isError: true, + }; + + expect(toProtocolMessage(SESSION_ID, 0, result, 0).content).toEqual([ + { type: 'tool_result', tool_call_id: 'call_err', output: 'boom', is_error: true }, + ]); + }); + + it('prefers the stored message id and falls back to the transcript index', () => { + const withId: ContextMessage = { ...userText('a'), id: 'msg_custom' }; + expect(toProtocolMessage(SESSION_ID, 7, withId, CREATED_AT).id).toBe('msg_custom'); + expect(toProtocolMessage(SESSION_ID, 7, userText('a'), CREATED_AT).id).toBe( + `msg_${SESSION_ID}_000007`, + ); + }); + + it('stamps created_at from the override or the session-created fallback', () => { + const msg = userText('a'); + expect(toProtocolMessage(SESSION_ID, 3, msg, CREATED_AT, CREATED_AT + 999).created_at).toBe( + new Date(CREATED_AT + 999).toISOString(), + ); + expect(toProtocolMessage(SESSION_ID, 3, msg, CREATED_AT).created_at).toBe( + new Date(CREATED_AT + 3).toISOString(), + ); + }); + + it('carries origin into metadata and omits metadata otherwise', () => { + const withOrigin: ContextMessage = { ...userText('a'), origin: { kind: 'user' } }; + expect(toProtocolMessage(SESSION_ID, 0, withOrigin, CREATED_AT).metadata).toEqual({ + origin: { kind: 'user' }, + }); + expect(toProtocolMessage(SESSION_ID, 0, userText('a'), CREATED_AT)).not.toHaveProperty( + 'metadata', + ); + }); +}); diff --git a/packages/kap-server/test/snapshot.test.ts b/packages/kap-server/test/snapshot.test.ts index a850ff10edb..004c82e9e08 100644 --- a/packages/kap-server/test/snapshot.test.ts +++ b/packages/kap-server/test/snapshot.test.ts @@ -9,16 +9,19 @@ import { join } from 'node:path'; import { type DomainEvent, + IAgentBlobService, IAgentContextMemoryService, + IAgentScopeContext, + IAppendLogStore, IEventBus, IAgentLifecycleService, IAgentPromptService, - ILogService, ISessionInteractionService, ISessionContext, ISessionIndex, ISessionMetadata, ISessionLifecycleService, + IWireService, IWorkspaceLifecycleService, IWorkspaceService, getLiveSessionById, @@ -28,7 +31,6 @@ import { sessionSnapshotResponseSchema } from '../src/protocol/rest-snapshot'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { registerSnapshotRoutes } from '../src/routes/snapshot'; -import { SnapshotNotFoundError } from '../src/services/snapshot'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; @@ -58,6 +60,9 @@ describe('server-v2 snapshot route enrichment', () => { IAgentPromptService, { list: () => ({ active: { id: promptId }, pending: [] }) }, ], + [IWireService, { flush: async () => {} }], + [IAgentScopeContext, { scope: () => 'scope/sess_snapshot' }], + [IAgentBlobService, { loadParts: async (parts: unknown) => parts }], ]), }; const session = { @@ -75,7 +80,7 @@ describe('server-v2 snapshot route enrichment', () => { }), }, ], - [IAgentLifecycleService, { get: () => main }], + [IAgentLifecycleService, { get: () => main, create: async () => main }], [ISessionInteractionService, { listPending: () => [] }], ]), }; @@ -107,6 +112,13 @@ describe('server-v2 snapshot route enrichment', () => { { handlerFor: async () => handler, handlers: { list: () => [] } }, ], [IWorkspaceService, { get: async () => ({ root: '/workspace' }) }], + [ + IAppendLogStore, + { + // No persisted records in this fake — the transcript is empty. + read: async function* () {}, + }, + ], ]), }; const broadcaster = { @@ -142,28 +154,17 @@ describe('server-v2 snapshot route enrichment', () => { reply: { send(payload: unknown): unknown }, ) => Promise | void) | undefined; - // Exercise the legacy (resume + live assembly) path — the fakes model the - // live scope, not the on-disk reader. - const previousReaderMode = process.env['KIMI_SNAPSHOT_READER']; - process.env['KIMI_SNAPSHOT_READER'] = 'legacy'; - const unusedReader = { read: async () => ({}) as never }; - try { - registerSnapshotRoutes( - { - get: (_path, _options, handler) => { - routeHandler = handler; - }, - }, - { - core: core as never, - broadcaster: broadcaster as never, - reader: unusedReader as never, + registerSnapshotRoutes( + { + get: (_path, _options, handler) => { + routeHandler = handler; }, - ); - } finally { - if (previousReaderMode === undefined) delete process.env['KIMI_SNAPSHOT_READER']; - else process.env['KIMI_SNAPSHOT_READER'] = previousReaderMode; - } + }, + { + core: core as never, + broadcaster: broadcaster as never, + }, + ); let payload: unknown; await routeHandler?.( @@ -196,83 +197,6 @@ describe('server-v2 snapshot route enrichment', () => { }); }); -describe('server-v2 snapshot route error mapping', () => { - function captureHandler( - deps: { core: unknown; broadcaster: unknown; reader: unknown }, - env?: Record, - ) { - const previous = new Map(); - for (const [k, v] of Object.entries(env ?? {})) { - previous.set(k, process.env[k]); - process.env[k] = v; - } - let handler: - | (( - req: { id: string; params: { session_id: string } }, - reply: { send(payload: unknown): unknown }, - ) => Promise | void) - | undefined; - try { - registerSnapshotRoutes( - { - get: (_path, _options, h) => { - handler = h; - }, - }, - deps as never, - ); - } finally { - for (const [k, v] of previous) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - } - return handler!; - } - - it('maps SnapshotNotFoundError to 40401', async () => { - const warns: unknown[] = []; - const core = { - accessor: fakeAccessor([[ILogService, { warn: (...a: unknown[]) => warns.push(a) }]]), - }; - const reader = { - read: async () => { - throw new SnapshotNotFoundError('sess_missing'); - }, - }; - const handler = captureHandler({ core, broadcaster: {}, reader }); - let payload: unknown; - await handler( - { id: 'req_404', params: { session_id: 'sess_missing' } }, - { send: (v) => (payload = v) }, - ); - expect((payload as { code: number }).code).toBe(40401); - expect(warns).toHaveLength(0); - }); - - it('maps SnapshotTimeoutError to 50001 and logs snapshot.timeout', async () => { - const warns: unknown[] = []; - const core = { - accessor: fakeAccessor([[ILogService, { warn: (...a: unknown[]) => warns.push(a) }]]), - }; - const reader = { - read: () => new Promise(() => {}), // hangs → triggers the timeout race - }; - const handler = captureHandler( - { core, broadcaster: {}, reader }, - { KIMI_SNAPSHOT_TIMEOUT_MS: '150' }, - ); - let payload: unknown; - await handler( - { id: 'req_to', params: { session_id: 'sess_slow' } }, - { send: (v) => (payload = v) }, - ); - expect((payload as { code: number }).code).toBe(50001); - expect(warns).toHaveLength(1); - expect((warns[0] as unknown[])[0]).toBe('snapshot.timeout'); - }); -}); - describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { let server: RunningServer | undefined; let home: string | undefined; @@ -369,9 +293,9 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { // Regression for the cold-session 404: a session that exists on disk but is // not live in this process (e.g. carried over from a prior process, or - // created by v1) must load from disk instead of returning 40401. We restart + // created by v1) must resume and load instead of returning 40401. We restart // the whole server on the same homeDir so the session is genuinely cold. - it('loads a cold (not live) session from disk instead of 404', async () => { + it('loads a cold (not live) session instead of 404', async () => { const sid = await createSession(); await server!.close(); @@ -386,11 +310,11 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { expect(snap.session.id).toBe(sid); }); - // The auto reader must source messages from `agents/main/wire.jsonl` on disk - // — not from a live (resumed) context. We seed a wire log, restart so the - // session is genuinely cold, then assert the snapshot returns the on-disk - // transcript while the scope stays un-materialized. - it('auto reader returns messages read directly from wire.jsonl for a cold session', async () => { + // A cold session's history comes through the engine path: the request + // resumes the session, replays the persisted journal, and serves the + // messages. We seed a wire log, restart so the session is genuinely cold, + // then assert the snapshot returns the persisted transcript. + it('returns the persisted transcript for a cold session', async () => { const sid = await createSession(); const live = getLiveSessionById(server!.core.accessor, sid); if (live === undefined) throw new Error(`session ${sid} not found`); @@ -424,7 +348,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { 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}`; - // Guard: still cold — the auto reader must serve from disk, not resume. + // Guard: still cold before the request — the snapshot itself resumes it. expect(getLiveSessionById(server!.core.accessor, sid)).toBeUndefined(); const snap = await snapshot(sid); diff --git a/packages/kap-server/test/snapshotReader.unit.test.ts b/packages/kap-server/test/snapshotReader.unit.test.ts deleted file mode 100644 index 7ec1f0c8136..00000000000 --- a/packages/kap-server/test/snapshotReader.unit.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -/** - * Unit tests for the server-layer disk reader (`services/snapshot`). - * - * Constructs `SnapshotReader` with stub core services and a real tmp `homeDir`, - * writing `state.json` + `agents/main/wire.jsonl` directly — exercising the - * disk read, the `context.*` reduction, the `(size, mtimeMs)` transcript cache, - * `state.json` normalization, and `KIMI_SNAPSHOT_*` config parsing without - * booting a Fastify daemon. - */ - -import { mkdir, rm, writeFile } from 'node:fs/promises'; -import { mkdtemp } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - ISessionIndex, - IWorkspaceLifecycleService, - IWorkspaceService, - type ContextMessage, - type SessionSummary, -} from '@moonshot-ai/agent-core-v2'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { - loadSnapshotConfig, - readWireRecords, - SnapshotNotFoundError, - SnapshotReader, - type SnapshotReaderDeps, -} from '../src/services/snapshot'; - -// ─── tiny stubs ─────────────────────────────────────────────────────────── - -function fakeAccessor(entries: ReadonlyArray) { - const services = new Map(entries); - return { - get(id: unknown): T { - if (!services.has(id)) throw new Error(`unexpected service request: ${String(id)}`); - return services.get(id) as T; - }, - }; -} - -const noopLogger = { info: () => {} }; - -interface Fixture { - homeDir: string; - workspaceId: string; - sessionDir: (sid: string) => string; - index: Map; - reader: SnapshotReader; - broadcaster: { seq: number; epoch: string; inFlightTurn: unknown }; -} - -const tmpDirs: string[] = []; - -async function makeFixtureAsync(opts?: { cacheLimit?: number }): Promise { - const homeDir = await mkdtemp(join(tmpdir(), 'kimi-snapshot-reader-')); - tmpDirs.push(homeDir); - const workspaceId = 'wd_unittest_012345abcdef'; - const index = new Map(); - const workspaces = new Map([[workspaceId, { root: join(homeDir, 'workspace') }]]); - - const core = { - accessor: fakeAccessor([ - [ISessionIndex, { get: async (sid: string) => index.get(sid) }], - [IWorkspaceService, { get: async (ws: string) => workspaces.get(ws) }], - // Cold by default — no live handle. - [IWorkspaceLifecycleService, { handlers: { list: () => [] } }], - ]), - }; - const broadcaster = { seq: 0, epoch: 'ep_unit', inFlightTurn: null }; - const deps: SnapshotReaderDeps = { - homeDir, - core: core as never, - broadcaster: { - getSnapshotState: async () => ({ - seq: broadcaster.seq, - epoch: broadcaster.epoch, - inFlightTurn: broadcaster.inFlightTurn as never, - subagents: [], - }), - } as never, - logger: noopLogger, - config: { mode: 'auto', timeoutMs: 4000, cacheLimit: opts?.cacheLimit ?? 32 }, - }; - return { - homeDir, - workspaceId, - sessionDir: (sid) => join(homeDir, 'sessions', workspaceId, sid), - index, - reader: new SnapshotReader(deps), - broadcaster, - }; -} - -function userMessage(text: string): ContextMessage { - return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; -} - -async function seedSession( - f: Fixture, - sid: string, - opts?: { createdAt?: number; title?: string; rawState?: Record }, -): Promise { - const createdAt = opts?.createdAt ?? 1700000000000; - f.index.set(sid, { - id: sid, - workspaceId: f.workspaceId, - title: opts?.title, - createdAt, - updatedAt: createdAt, - archived: false, - }); - const state = opts?.rawState ?? { - id: sid, - version: 2, - createdAt, - updatedAt: createdAt, - archived: false, - title: opts?.title, - }; - await mkdir(f.sessionDir(sid), { recursive: true }); - await writeFile(join(f.sessionDir(sid), 'state.json'), JSON.stringify(state), 'utf-8'); -} - -async function writeWire(sessionDir: string, lines: ReadonlyArray): Promise { - const agentDir = join(sessionDir, 'agents', 'main'); - await mkdir(agentDir, { recursive: true }); - const body = lines.map((l) => JSON.stringify(l)).join('\n') + (lines.length > 0 ? '\n' : ''); - await writeFile(join(agentDir, 'wire.jsonl'), body, 'utf-8'); -} - -afterEach(async () => { - for (const dir of tmpDirs.splice(0)) await rm(dir, { recursive: true, force: true }); -}); - -// ─── SnapshotReader.read ────────────────────────────────────────────────── - -describe('SnapshotReader.read', () => { - it('throws SnapshotNotFoundError for an unknown session', async () => { - const f = await makeFixtureAsync(); - await expect(f.reader.read('sess_missing')).rejects.toBeInstanceOf(SnapshotNotFoundError); - }); - - it('throws SnapshotNotFoundError when the workspace is gone', async () => { - const f = await makeFixtureAsync(); - f.index.set('sess_orphan', { - id: 'sess_orphan', - workspaceId: 'wd_gone_000000000000', - createdAt: 1, - updatedAt: 1, - archived: false, - }); - await expect(f.reader.read('sess_orphan')).rejects.toBeInstanceOf(SnapshotNotFoundError); - }); - - it('returns empty messages for a session with no wire.jsonl', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_empty'); - const snap = await f.reader.read('sess_empty'); - expect(snap.session.id).toBe('sess_empty'); - expect(snap.session.busy).toBe(false); - expect(snap.messages.items).toEqual([]); - expect(snap.messages.has_more).toBe(false); - expect(snap.in_flight_turn).toBeNull(); - expect(snap.subagents).toEqual([]); - expect(snap.pending_approvals).toEqual([]); - expect(snap.as_of_seq).toBe(0); - expect(snap.epoch).toBe('ep_unit'); - }); - - it('builds messages from context.append_message records', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_msgs'); - await writeWire(f.sessionDir('sess_msgs'), [ - { type: 'metadata', protocol_version: '1.4', created_at: 1 }, - { type: 'context.append_message', message: userMessage('one') }, - { type: 'context.append_message', message: userMessage('two') }, - ]); - const snap = await f.reader.read('sess_msgs'); - expect(snap.messages.items).toHaveLength(2); - expect(snap.messages.items.map((m) => (m.content[0] as { text: string }).text)).toEqual([ - 'one', - 'two', - ]); - }); - - it('folds v1 context.append_loop_event records into assistant and tool messages', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_loop'); - await writeWire(f.sessionDir('sess_loop'), [ - { type: 'metadata', protocol_version: '1.4', created_at: 1 }, - { type: 'context.append_message', message: userMessage('question') }, - { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1', turnId: '0', step: 1 } }, - { - type: 'context.append_loop_event', - event: { - type: 'content.part', - uuid: 'p1', - turnId: '0', - step: 1, - stepUuid: 's1', - part: { type: 'text', text: 'hello' }, - }, - }, - { - type: 'context.append_loop_event', - event: { - type: 'tool.call', - uuid: 'c1', - turnId: '0', - step: 1, - stepUuid: 's1', - toolCallId: 'call_1', - name: 'Bash', - args: { command: 'echo hi' }, - }, - }, - { - type: 'context.append_loop_event', - event: { - type: 'tool.result', - parentUuid: 'c1', - toolCallId: 'call_1', - result: { output: 'hi' }, - }, - }, - { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's1', turnId: '0', step: 1 } }, - ]); - const snap = await f.reader.read('sess_loop'); - expect(snap.messages.items.map((m) => m.role)).toEqual(['user', 'assistant', 'tool']); - const assistant = snap.messages.items[1]!; - expect((assistant.content[0] as { text: string }).text).toBe('hello'); - const toolUse = assistant.content.find((p) => p.type === 'tool_use') as - | { tool_call_id: string; tool_name: string } - | undefined; - expect(toolUse?.tool_call_id).toBe('call_1'); - expect(toolUse?.tool_name).toBe('Bash'); - const tool = snap.messages.items[2]!; - expect(tool.role).toBe('tool'); - expect((tool.content[0] as { tool_call_id: string }).tool_call_id).toBe('call_1'); - }); - - it('keeps the full history across context.apply_compaction and appends a summary marker', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_compact'); - await writeWire(f.sessionDir('sess_compact'), [ - { type: 'context.append_message', message: userMessage('old-1') }, - { type: 'context.append_message', message: userMessage('old-2') }, - { - type: 'context.apply_compaction', - count: 2, - summary: { role: 'user', content: [{ type: 'text', text: 'summary' }], toolCalls: [] }, - }, - { type: 'context.append_message', message: userMessage('after') }, - ]); - const snap = await f.reader.read('sess_compact'); - const texts = snap.messages.items.map((m) => (m.content[0] as { text: string }).text); - expect(texts).toEqual(['old-1', 'old-2', 'summary', 'after']); - expect(snap.messages.items[2]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } }); - }); - - it('keeps the full history across v1-shaped string summary compaction records', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_compact_v1'); - await writeWire(f.sessionDir('sess_compact_v1'), [ - { type: 'context.append_message', message: userMessage('old-1') }, - { type: 'context.append_message', message: userMessage('old-2') }, - { - type: 'context.apply_compaction', - summary: 'summary', - compactedCount: 2, - tokensBefore: 100, - tokensAfter: 20, - }, - { type: 'context.append_message', message: userMessage('after') }, - ]); - const snap = await f.reader.read('sess_compact_v1'); - const messages = snap.messages.items; - const texts = messages.map((m) => (m.content[0] as { text: string }).text); - expect(texts).toEqual(['old-1', 'old-2', 'summary', 'after']); - expect(messages[2]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } }); - }); - - it('keeps compacted-away assistant messages and uses the raw summary as the marker', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_compact_kept_users'); - await writeWire(f.sessionDir('sess_compact_kept_users'), [ - { type: 'context.append_message', message: userMessage('old user') }, - { - type: 'context.append_message', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'old assistant' }], - toolCalls: [], - }, - }, - { type: 'context.append_message', message: userMessage('recent user') }, - { - type: 'context.apply_compaction', - summary: 'raw summary', - contextSummary: 'model-facing summary', - compactedCount: 3, - tokensBefore: 100, - tokensAfter: 20, - keptUserMessageCount: 2, - }, - ]); - const snap = await f.reader.read('sess_compact_kept_users'); - const messages = snap.messages.items; - const texts = messages.map((m) => (m.content[0] as { text: string }).text); - expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user', 'user']); - expect(texts).toEqual(['old user', 'old assistant', 'recent user', 'raw summary']); - expect(messages[3]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } }); - }); - - it('preserves the pre-compaction assistant reply after a later undo', async () => { - // Regression: send A, /compact, send B, undo. The snapshot must still show - // A's assistant reply (compaction folds only the live context; the - // transcript keeps the full history). - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_compact_undo'); - const assistant = (text: string): ContextMessage => ({ - role: 'assistant', - content: [{ type: 'text', text }], - toolCalls: [], - }); - await writeWire(f.sessionDir('sess_compact_undo'), [ - { type: 'context.append_message', message: userMessage('message A') }, - { type: 'context.append_message', message: assistant('reply A') }, - { - type: 'context.apply_compaction', - summary: 'summary text', - contextSummary: 'model-facing summary', - compactedCount: 2, - tokensBefore: 100, - tokensAfter: 20, - keptUserMessageCount: 1, - }, - { type: 'context.append_message', message: userMessage('message B') }, - { type: 'context.append_message', message: assistant('reply B') }, - { type: 'context.undo', count: 1 }, - ]); - const snap = await f.reader.read('sess_compact_undo'); - const messages = snap.messages.items; - expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user']); - expect(messages.map((m) => (m.content[0] as { text: string }).text)).toEqual([ - 'message A', - 'reply A', - 'summary text', - ]); - }); - - it('keeps pre-clear messages in the transcript and lets undo remove the tail', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_ops'); - await writeWire(f.sessionDir('sess_ops'), [ - { type: 'context.append_message', message: userMessage('a') }, - { type: 'context.append_message', message: userMessage('b') }, - { type: 'context.clear' }, - { type: 'context.append_message', message: userMessage('c') }, - { type: 'context.undo', count: 1 }, - ]); - // /clear keeps prior messages for display; undo removes the post-clear tail (c). - expect((await f.reader.read('sess_ops')).messages.items.map((m) => (m.content[0] as { text: string }).text)).toEqual(['a', 'b']); - }); - - it('caps the page at 100 and flags has_more', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_paged'); - await writeWire( - f.sessionDir('sess_paged'), - Array.from({ length: 150 }, (_, i) => ({ - type: 'context.append_message' as const, - message: userMessage(`m${i}`), - })), - ); - const snap = await f.reader.read('sess_paged'); - expect(snap.messages.items).toHaveLength(100); - expect(snap.messages.has_more).toBe(true); - expect((snap.messages.items[0]!.content[0] as { text: string }).text).toBe('m50'); - expect((snap.messages.items.at(-1)!.content[0] as { text: string }).text).toBe('m149'); - }); - - it('uses the wire record time as created_at, falling back and clamping to stay increasing', async () => { - const f = await makeFixtureAsync(); - const createdAt = 1700000000000; - const t0 = 1700001000000; - await seedSession(f, 'sess_times', { createdAt }); - await writeWire(f.sessionDir('sess_times'), [ - { type: 'context.append_message', message: userMessage('one'), time: t0 }, - // No time stamp → falls back to createdAt + index, which is earlier than - // the previous real time and gets clamped to previous + 1. - { type: 'context.append_message', message: userMessage('two') }, - // A time stamp EARLIER than the previous entry → clamped to previous + 1. - { type: 'context.append_message', message: userMessage('three'), time: t0 - 5000 }, - ]); - const snap = await f.reader.read('sess_times'); - expect(snap.messages.items.map((m) => Date.parse(m.created_at))).toEqual([t0, t0 + 1, t0 + 2]); - }); - - it('synthesizes created_at from session createdAt + index when no record carries a time', async () => { - const f = await makeFixtureAsync(); - const createdAt = 1700000000000; - await seedSession(f, 'sess_no_times', { createdAt }); - await writeWire(f.sessionDir('sess_no_times'), [ - { type: 'context.append_message', message: userMessage('a') }, - { type: 'context.append_message', message: userMessage('b') }, - ]); - const snap = await f.reader.read('sess_no_times'); - expect(snap.messages.items.map((m) => Date.parse(m.created_at))).toEqual([ - createdAt, - createdAt + 1, - ]); - }); - - it('maps record times by global index across the page offset', async () => { - const f = await makeFixtureAsync(); - const createdAt = 1700000000000; - const base = 1700002000000; - await seedSession(f, 'sess_times_paged', { createdAt }); - await writeWire( - f.sessionDir('sess_times_paged'), - Array.from({ length: 102 }, (_, i) => ({ - type: 'context.append_message' as const, - message: userMessage(`m${i}`), - time: base + i * 1000, - })), - ); - const snap = await f.reader.read('sess_times_paged'); - expect(snap.messages.items).toHaveLength(100); - // The page starts at global index 2, so the first item carries record[2]'s time. - expect(Date.parse(snap.messages.items[0]!.created_at)).toBe(base + 2000); - expect(Date.parse(snap.messages.items.at(-1)!.created_at)).toBe(base + 101 * 1000); - }); - - it('normalizes a v1-layout state.json (ISO timestamps, no id)', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_v1', { - rawState: { - title: 'v1 session', - createdAt: '2026-06-01T10:00:00.000Z', - updatedAt: '2026-06-01T11:00:00.000Z', - archived: false, - }, - }); - const snap = await f.reader.read('sess_v1'); - expect(snap.session.id).toBe('sess_v1'); - expect(snap.session.title).toBe('v1 session'); - expect(Number.isNaN(Date.parse(snap.session.created_at))).toBe(false); - }); - - it('serves repeated reads from the (size, mtime) cache', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_cache'); - await writeWire(f.sessionDir('sess_cache'), [ - { type: 'context.append_message', message: userMessage('cached') }, - ]); - const first = await f.reader.read('sess_cache'); - expect(first.messages.items).toHaveLength(1); - // Rewrite with identical content (size + mtime may change) — the cache is - // keyed on (size, mtime); a same-size rewrite keeps serving the cached - // reduction only when mtime is unchanged, so just assert stability. - const second = await f.reader.read('sess_cache'); - expect(second.messages.items.map((m) => (m.content[0] as { text: string }).text)).toEqual([ - 'cached', - ]); - }); - - it('invalidates the cache when the wire shrinks (compaction rewrite)', async () => { - const f = await makeFixtureAsync(); - await seedSession(f, 'sess_shrink'); - await writeWire(f.sessionDir('sess_shrink'), [ - { type: 'context.append_message', message: userMessage('a') }, - { type: 'context.append_message', message: userMessage('b') }, - { type: 'context.append_message', message: userMessage('c') }, - ]); - expect((await f.reader.read('sess_shrink')).messages.items).toHaveLength(3); - await new Promise((r) => setTimeout(r, 20)); - await writeWire(f.sessionDir('sess_shrink'), [ - { type: 'context.append_message', message: userMessage('only-one') }, - ]); - const snap = await f.reader.read('sess_shrink'); - expect(snap.messages.items).toHaveLength(1); - expect((snap.messages.items[0]!.content[0] as { text: string }).text).toBe('only-one'); - }); -}); - -describe('readWireRecords', () => { - it('drops a torn final line but throws on mid-file corruption', async () => { - const dir = await mkdtemp(join(tmpdir(), 'kimi-wire-')); - tmpDirs.push(dir); - const p = join(dir, 'wire.jsonl'); - await writeFile( - p, - '{"type":"context.append_message","message":{"role":"user","content":[],"toolCalls":[]}}\n{"type":"context.append_message","message":{"role":"user","cont', - 'utf-8', - ); - const records = await readWireRecords(p); - expect(records).toHaveLength(1); - - const bad = join(dir, 'bad.jsonl'); - await writeFile(bad, '{not-json}\n{"type":"context.append_message"}\n', 'utf-8'); - await expect(readWireRecords(bad)).rejects.toThrow(/corrupted line 1/); - }); -}); - -describe('loadSnapshotConfig', () => { - it('defaults to auto / 4000ms / 32', () => { - const c = loadSnapshotConfig({}); - expect(c).toEqual({ mode: 'auto', timeoutMs: 4000, cacheLimit: 32 }); - }); - - it('parses legacy mode and integer knobs with floors', () => { - const c = loadSnapshotConfig({ - KIMI_SNAPSHOT_READER: 'legacy', - KIMI_SNAPSHOT_TIMEOUT_MS: '2500', - KIMI_SNAPSHOT_CACHE_LIMIT: '0', // below min → default - }); - expect(c.mode).toBe('legacy'); - expect(c.timeoutMs).toBe(2500); - expect(c.cacheLimit).toBe(32); - }); - - it('falls back on non-numeric / sub-minimum timeout', () => { - expect(loadSnapshotConfig({ KIMI_SNAPSHOT_TIMEOUT_MS: 'abc' }).timeoutMs).toBe(4000); - expect(loadSnapshotConfig({ KIMI_SNAPSHOT_TIMEOUT_MS: '50' }).timeoutMs).toBe(4000); - }); -}); diff --git a/packages/kap-server/test/workspaceLayout.test.ts b/packages/kap-server/test/workspaceLayout.test.ts index 6041e0991cc..82865c47891 100644 --- a/packages/kap-server/test/workspaceLayout.test.ts +++ b/packages/kap-server/test/workspaceLayout.test.ts @@ -10,7 +10,7 @@ * `sessions/{wd_id}/{sid}/state.json`, and * `sessions/{wd_id}/{sid}/agents/main/wire.jsonl` (metadata envelope first, * `agents.main.homedir` written with the original value). Also proves the - * disk-reading `ISnapshotReader` serves the layout end-to-end. + * snapshot route serves the layout end-to-end (cold resume from disk). * Wiring: real kap-server on a temp home. * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/workspaceLayout.test.ts`. */ From da4c6ff54d3eeea65e144d937591888f847ab474 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 3 Aug 2026 20:17:01 +0800 Subject: [PATCH 09/33] feat(config): add deprecation mechanism and rename loop retry limit (#2572) * feat(config): add deprecation mechanism and rename loop retry limit - agent-core-v2 config: declarative section `deprecations` (deprecated TOML keys are ignored and report a warning diagnostic; the file is never rewritten) and env binding `deprecatedEnv` (old var still resolves as a fallback with a warning), surfaced via the new `IConfigService.onDidChangeDiagnostics` event - loop_control: rename `max_retries_per_step` to `max_attempts_per_step` and `KIMI_LOOP_MAX_RETRIES_PER_STEP` to `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP`; `max_steps_per_run` moves onto the same mechanism (no longer silently mapped) - kap-server: push the global `event.config.warning` WS event to every connection whenever the config warning set changes - TUI: show config diagnostics in warning yellow at startup instead of the dim startup notice - docs: config-files/env-vars (en+zh), regenerated config manifest, and the agent-core-dev config guide * feat(cli): validate config.toml against v2 section registry in doctor - add v2/validate-config.ts: validate config.toml with the agent-core-v2 ConfigRegistry, reporting registered-section schema failures as errors and unknown top-level keys / deprecated keys and env vars as non-fatal warnings - route `kimi doctor` config validation through the v2 validator when the KIMI_CODE_EXPERIMENTAL_FLAG master switch is on (lazy dynamic import, keeping the v2 module graph off the default path) - let doctor checks surface non-fatal warning messages on OK results * chore: downgrade loop-control changeset to patch --- .agents/skills/agent-core-dev/config.md | 28 ++- .changeset/kap-server-config-warning-event.md | 5 + .../loop-control-attempt-limit-rename.md | 5 + apps/kimi-code/src/cli/experimental-v2.ts | 9 +- apps/kimi-code/src/cli/run-shell.ts | 7 +- apps/kimi-code/src/cli/sub/doctor.ts | 24 +- apps/kimi-code/src/cli/v2/validate-config.ts | 187 +++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 4 + apps/kimi-code/test/cli/doctor.test.ts | 139 +++++++++++ apps/kimi-code/test/cli/run-shell.test.ts | 7 +- docs/en/configuration/config-files.md | 10 +- docs/en/configuration/env-vars.md | 2 +- docs/zh/configuration/config-files.md | 10 +- docs/zh/configuration/env-vars.md | 2 +- .../agent-core-v2/docs/config-manifest.toml | 9 +- .../scripts/gen-config-manifest.mts | 11 + .../src/agent/loop/configSection.ts | 44 ++-- .../src/agent/stepRetry/stepRetryService.ts | 2 +- .../agent-core-v2/src/app/config/config.ts | 62 ++++- .../src/app/config/configService.ts | 137 +++++++++-- .../src/app/config/deprecations.ts | 41 ++++ .../app/skillCatalog/builtin/update-config.md | 9 +- .../test/agent/goal/goal.test.ts | 2 +- .../test/agent/stepRetry/stepRetry.test.ts | 4 +- .../test/app/config/config.test.ts | 227 ++++++++++++++++-- packages/agent-core-v2/test/kosong/stubs.ts | 3 + .../src/skill/builtin/update-config.md | 9 +- .../kap-server/src/protocol/events-zod.ts | 10 + packages/kap-server/src/start.ts | 35 +++ .../kap-server/src/transport/ws/v1/events.ts | 18 ++ .../ws/v1/sessionEventBroadcaster.ts | 46 +++- .../test/sessionEventBroadcaster.test.ts | 43 ++++ .../test/e2e/invalid-input-matrix.test.ts | 4 +- 33 files changed, 1049 insertions(+), 106 deletions(-) create mode 100644 .changeset/kap-server-config-warning-event.md create mode 100644 .changeset/loop-control-attempt-limit-rename.md create mode 100644 apps/kimi-code/src/cli/v2/validate-config.ts create mode 100644 packages/agent-core-v2/src/app/config/deprecations.ts diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 2eb48516eb8..6529924c117 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -152,15 +152,18 @@ registerSection('providers', ProvidersSectionSchema, { ``` Each field is an `EnvBinding` — a string (env var name) or -`{ env, parse?, default? }`. IConfig resolves every field by +`{ env, deprecatedEnv?, parse?, default? }`. IConfig resolves every field by `env > config.toml > default`, sets it on the effective value, and validates the section. Empty nested entries (no field resolved) are omitted, so a synthetic entry like `__kimi_env__` only appears when at least one of its env vars is set. +When `deprecatedEnv` is set and `env` itself is absent or fails `parse`, the +deprecated var still supplies the value and a warning diagnostic is reported — +use it to rename an env var without breaking existing setups. `stripEnv(value, raw?, getEnv?)` removes env-derived fields before `set`/`replace` persists, so env overrides never leak into `config.toml`. `raw` is the section's -env-free camelCase base (already `fromToml`-normalized, so legacy key renames -are honored), and `getEnv` reads the live env bag. For fields that are **both +env-free camelCase base (already `fromToml`-normalized), and `getEnv` reads the +live env bag. For fields that are **both user-persistable and env-overridable**, register `stripEnv: stripEnvBoundFields(sectionEnvBindings)` (from `#/app/config/config`) — it derives the guard from the same bindings the read path uses: while a @@ -231,7 +234,7 @@ This means registration order is never a correctness concern — you do not need `config.toml` stores keys in **snake_case**; in-memory values are **camelCase**. `ConfigService` converts both ways by dispatching to each section's registered transform: -- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `loop_control.max_steps_per_run` → `maxStepsPerTurn`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern. +- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern. - **Write**: `applySectionToToml(rawSnake, domain, value, registry)` applies the domain's `toToml` hook (or a plain camelCase→snake_case mapping) into a raw clone of the file, preserving unknown top-level keys and unknown sub-fields (lossless round-trip). `ConfigService` keeps four views: @@ -241,6 +244,23 @@ This means registration order is never a correctness concern — you do not need - `validated` — validated `raw`, env-free; the base every live env re-application starts from, so a degraded or removed env value falls back to the file instead of a stale overlay. - `effective` — `validated` plus the env overlay, recomputed on load/set; `get()`/`getAll()` re-apply the overlay on a fresh `validated` copy per read rather than caching it. +### Renaming config keys and env vars (deprecations) + +Renames are declared once on the section, never hand-rolled in `fromToml`: + +```ts +registerSection(MY_SECTION, MySectionSchema, { + deprecations: [{ key: 'old_key', replacement: 'new_key' }], // snake_case, on-disk + env: envBindings(MySectionSchema, { + newKey: { env: 'KIMI_NEW_KEY', deprecatedEnv: 'KIMI_OLD_KEY', parse }, + }), +}); +``` + +- A deprecated TOML key is **ignored** (its value no longer applies — the schema only knows the new key) and reports a warning `ConfigDiagnostic` while present; the file is never rewritten, so the warning is the migration guide. Diagnostics are recomputed on every load/reload and surface to clients via `IConfigService.diagnostics()` and `onDidChangeDiagnostics` (kap-server republishes them as the global `event.config.warning` WS event). +- A deprecated env var still **resolves** as a fallback (new var first), with the same warning treatment, and `stripEnvBoundFields` treats it as env-owned for writes. +- See `src/agent/loop/configSection.ts` for a worked example (`max_retries_per_step` → `max_attempts_per_step`). + ### `KIMI_MODEL_*` env overlay When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics. diff --git a/.changeset/kap-server-config-warning-event.md b/.changeset/kap-server-config-warning-event.md new file mode 100644 index 00000000000..7312d1e3e24 --- /dev/null +++ b/.changeset/kap-server-config-warning-event.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kap-server": patch +--- + +Add the global `event.config.warning` WebSocket event that pushes the current set of config warnings (deprecated config keys or environment variables in use) to every connection whenever it changes. diff --git a/.changeset/loop-control-attempt-limit-rename.md b/.changeset/loop-control-attempt-limit-rename.md new file mode 100644 index 00000000000..2f81943bbac --- /dev/null +++ b/.changeset/loop-control-attempt-limit-rename.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Rename the `[loop_control] max_retries_per_step` config key to `max_attempts_per_step` and `max_steps_per_run` to `max_steps_per_turn`: on the v2 engine the old keys no longer take effect and a startup warning prompts the rename in `config.toml`. The `KIMI_LOOP_MAX_RETRIES_PER_STEP` env var is likewise deprecated in favor of `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` but keeps working with a warning. diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts index 4f53508baeb..5bcbf13396d 100644 --- a/apps/kimi-code/src/cli/experimental-v2.ts +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -3,10 +3,11 @@ * * When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, `kimi -p` * (print mode) routes to the native agent-core-v2 runner (see - * `run-prompt.ts`) and the interactive TUI builds its harness through the - * SDK's v2-backed client (see `run-shell.ts`), both instead of the default - * v1 engine. The master switch also enables every experimental feature flag - * in the engine. Read directly from the env (matching + * `run-prompt.ts`), the interactive TUI builds its harness through the + * SDK's v2-backed client (see `run-shell.ts`), and `kimi doctor` validates + * config.toml against the v2 section registry (see `sub/doctor.ts` / + * `v2/validate-config.ts`), all instead of the default v1 engine. The + * master switch also enables every experimental feature flag in the engine. Read directly from the env (matching * `cli/update/rollout.ts`) because the CLI must not depend on the core flag * registry. Unset / any non-truthy value keeps the v1 path. * diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 84ad5897bb7..3e1a9b89c24 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -26,7 +26,6 @@ import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { KimiTUI } from '#/tui/index'; import { currentTheme, getColorPalette } from '#/tui/theme'; -import { combineStartupNotice } from '#/tui/utils/startup'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; @@ -108,9 +107,9 @@ export async function runShell( return; } const config = await harness.getConfig(); - for (const warning of (await harness.getConfigDiagnostics()).warnings) { - configWarning = combineStartupNotice(configWarning, warning); - } + // Config diagnostics (deprecated keys, invalid sections, ...) are surfaced + // by the TUI itself at `finishStartup` via `showConfigWarningsIfAny` — + // folded into the dim startup notice they were too easy to miss. const configMs = Date.now() - configStartedAt; // Resolve --agent/--agent-file once for the startup session; validateOptions // has already rejected them alongside --session/--continue. diff --git a/apps/kimi-code/src/cli/sub/doctor.ts b/apps/kimi-code/src/cli/sub/doctor.ts index 0ccc38d281f..d6d5db3d1b9 100644 --- a/apps/kimi-code/src/cli/sub/doctor.ts +++ b/apps/kimi-code/src/cli/sub/doctor.ts @@ -10,6 +10,7 @@ import { import type { Command } from 'commander'; import { z } from 'zod'; +import { isKimiV2Enabled } from '#/cli/experimental-v2'; import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; interface WritableLike { @@ -28,7 +29,7 @@ export interface DoctorDeps { readonly configRpc?: KimiConfigRpc; readonly fileExists?: (path: string) => boolean; readonly readTextFile?: (path: string) => Promise; - readonly validateConfigToml?: (text: string, path: string) => MaybePromise; + readonly validateConfigToml?: (text: string, path: string) => MaybePromise; } export interface DoctorOptions { @@ -40,7 +41,8 @@ interface CheckSpec { readonly label: 'config.toml' | 'tui.toml'; readonly path: string; readonly explicit: boolean; - readonly parse: (text: string, path: string) => MaybePromise; + /** Throws on invalid content; may return a non-fatal warning message. */ + readonly parse: (text: string, path: string) => MaybePromise; } interface CheckResult { @@ -59,7 +61,7 @@ interface ResolvedDoctorDeps { readonly exit: (code: number) => never; readonly fileExists: (path: string) => boolean; readonly readTextFile: (path: string) => Promise; - readonly validateConfigToml: (text: string, path: string) => MaybePromise; + readonly validateConfigToml: (text: string, path: string) => MaybePromise; } export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise { @@ -130,7 +132,17 @@ function resolveDeps(deps: Partial | DoctorDeps | undefined): Resolv readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')), validateConfigToml: deps?.validateConfigToml ?? - ((text, filePath) => getConfigRpc().validateConfigToml({ text, filePath })), + (async (text, filePath) => { + if (isKimiV2Enabled()) { + // Experimental v2 route (same master switch as `kimi -p`): validate + // with the agent-core-v2 section registry instead of the v1 schema. + // Loaded lazily so the v2 module graph stays off the default path. + const { validateConfigTomlV2 } = await import('../v2/validate-config'); + return validateConfigTomlV2(text, filePath); + } + await getConfigRpc().validateConfigToml({ text, filePath }); + return undefined; + }), }; } @@ -204,8 +216,8 @@ async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise try { const text = await deps.readTextFile(spec.path); - await spec.parse(text, spec.path); - return { label: spec.label, path: spec.path, status: 'OK' }; + const warning = await spec.parse(text, spec.path); + return { label: spec.label, path: spec.path, status: 'OK', message: warning ?? undefined }; } catch (error) { return { label: spec.label, diff --git a/apps/kimi-code/src/cli/v2/validate-config.ts b/apps/kimi-code/src/cli/v2/validate-config.ts new file mode 100644 index 00000000000..89d14869b5b --- /dev/null +++ b/apps/kimi-code/src/cli/v2/validate-config.ts @@ -0,0 +1,187 @@ +/** + * Experimental v2 config.toml validation for `kimi doctor`. + * + * Loaded lazily (dynamic import) by the doctor command only when the + * agent-core-v2 master switch (`KIMI_CODE_EXPERIMENTAL_FLAG`) is on, so the + * v2 module graph stays off the default (v1) doctor path. Validation uses the + * engine's own section registry instead of v1's whole-document strict schema: + * importing the package root runs every built-in section's side-effect + * registration ("import = register"), and `ConfigRegistry` is then + * constructed directly — no DI container, no `ConfigService`, no file IO. + * + * Semantics deliberately mirror the v2 engine rather than v1: + * - a registered section that fails schema validation is an error (the + * engine would silently ignore that section at runtime; surfacing it is + * doctor's job); + * - a top-level key with no registered section passes through the engine + * untouched, so it is reported as a non-fatal warning — except the known + * schema-less domains the engine consumes directly (`default_model`, …); + * - section-declared key renames (`deprecations`) and renamed env vars + * (`deprecatedEnv` bindings actually supplying a value) surface as + * non-fatal warnings, reusing the engine's own detection + * (`collectKeyDeprecations`) and mirroring `ConfigService`'s env-fallback + * warning rule. + */ + +import { parse as parseToml } from 'smol-toml'; +import { z } from 'zod'; + +import { + ConfigRegistry, + type AnyEnvBindings, + type EnvBinding, +} from '@moonshot-ai/agent-core-v2'; +import { collectKeyDeprecations } from '@moonshot-ai/agent-core-v2/app/config/deprecations'; +import { + camelToSnake, + describeTomlSyntaxError, + isPlainObject, + transformTomlData, +} from '@moonshot-ai/agent-core-v2/app/config/toml'; + +/** + * Top-level domains the v2 engine reads via `IConfigService.get` / `inspect` + * without registering a schema (free-form values, structurally validated + * nowhere): `defaultModel` / `defaultProvider` (`kosongConfig` default + * pointers), `modelOverrides` (`llmRequester` / `profile`), and `telemetry` + * (read by the CLI itself). + */ +const SCHEMALESS_DOMAINS: ReadonlySet = new Set([ + 'defaultModel', + 'defaultProvider', + 'modelOverrides', + 'telemetry', +]); + +interface V2ConfigValidationIssue { + readonly path: readonly (string | number)[]; + readonly message: string; +} + +/** + * Matches the shape `handleDoctor` extracts from `error.details` (the SDK's + * `KimiConfigValidationIssue` list), so the doctor formatter renders v2 + * issues exactly like v1 ones. + */ +class V2ConfigValidationError extends Error { + readonly details: { readonly validationIssues: readonly V2ConfigValidationIssue[] }; + + constructor(issues: readonly V2ConfigValidationIssue[]) { + super('v2 config validation failed'); + this.details = { validationIssues: issues }; + } +} + +/** + * Validate `text` as config.toml against the v2 engine's section registry. + * Throws on TOML syntax errors and on any registered section failing its + * schema; returns non-fatal warnings (one per line) for unknown top-level + * keys, deprecated config keys, and deprecated env vars in use. + */ +export function validateConfigTomlV2( + text: string, + filePath: string, + getEnv: (name: string) => string | undefined = (name) => process.env[name], +): string | undefined { + let data: Record = {}; + if (text.trim().length > 0) { + try { + data = parseToml(text) as Record; + } catch (error) { + throw new Error(`Invalid TOML in ${filePath}: ${describeTomlSyntaxError(error)}`, { + cause: error, + }); + } + } + + const registry = new ConfigRegistry(); + const transformed = transformTomlData(data, registry); + + const issues: V2ConfigValidationIssue[] = []; + const unknownKeys: string[] = []; + for (const [domain, value] of Object.entries(transformed)) { + if (registry.getSection(domain) === undefined) { + if (!SCHEMALESS_DOMAINS.has(domain)) unknownKeys.push(camelToSnake(domain)); + continue; + } + try { + registry.validate(domain, value); + } catch (error) { + if (!(error instanceof z.ZodError)) throw error; + for (const issue of error.issues) { + issues.push({ + path: [ + domain, + ...issue.path.map((segment) => + typeof segment === 'number' ? segment : String(segment), + ), + ], + message: issue.message, + }); + } + } + } + + if (issues.length > 0) throw new V2ConfigValidationError(issues); + + const warnings: string[] = []; + for (const diagnostic of collectKeyDeprecations(data, registry.listSections())) { + warnings.push(diagnostic.message); + } + warnings.push(...collectEnvDeprecations(registry, getEnv)); + if (unknownKeys.length > 0) { + warnings.push( + `Unknown top-level ${unknownKeys.length === 1 ? 'key' : 'keys'} ignored by the v2 engine: ${unknownKeys.join(', ')}.`, + ); + } + return warnings.length > 0 ? warnings.join('\n') : undefined; +} + +/** + * Warn about renamed env vars that actually supply a value, mirroring + * `ConfigService`'s `resolveBinding`: the deprecated name only resolves (and + * thus only warns) when the primary var is absent or fails to parse. + */ +function collectEnvDeprecations( + registry: ConfigRegistry, + getEnv: (name: string) => string | undefined, +): string[] { + const warnings = new Set(); + for (const section of registry.listSections()) { + if (section.env === undefined) continue; + walkEnvBindings(section.env, (binding) => { + if (typeof binding === 'string' || binding.deprecatedEnv === undefined) return; + const primary = getEnv(binding.env); + if ( + primary !== undefined && + (binding.parse === undefined || binding.parse(primary) !== undefined) + ) { + return; + } + const deprecated = getEnv(binding.deprecatedEnv); + if (deprecated === undefined) return; + if (binding.parse !== undefined && binding.parse(deprecated) === undefined) return; + warnings.add( + `Environment variable ${binding.deprecatedEnv} is deprecated; use ${binding.env} instead.`, + ); + }); + } + return [...warnings]; +} + +function isEnvBinding(value: AnyEnvBindings): value is EnvBinding { + return typeof value === 'string' || (isPlainObject(value) && 'env' in value); +} + +function walkEnvBindings( + bindings: AnyEnvBindings, + visit: (binding: EnvBinding) => void, +): void { + if (isEnvBinding(bindings)) { + visit(bindings); + return; + } + for (const value of Object.values(bindings)) { + if (value !== undefined) walkEnvBindings(value, visit); + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 834bd777712..c64c65dff4b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -703,6 +703,10 @@ export class KimiTUI { this.startupNotice = undefined; } void this.showTmuxKeyboardWarningIfNeeded(); + // Config diagnostics (deprecated keys/env vars, invalid sections) in + // warning yellow at boot; `run-prompt`/`run-v2-print` print them to + // stderr for non-interactive runs. + void this.showConfigWarningsIfAny(); if (this.state.startupState === 'picker') { void this.bootstrapFromPicker(); return; diff --git a/apps/kimi-code/test/cli/doctor.test.ts b/apps/kimi-code/test/cli/doctor.test.ts index afbda67c014..0e2024617c7 100644 --- a/apps/kimi-code/test/cli/doctor.test.ts +++ b/apps/kimi-code/test/cli/doctor.test.ts @@ -268,3 +268,142 @@ max_context_size = "large" expect(err).toContain('models.kimi.max_context_size:'); }); }); + +describe('kimi doctor (v2 config validation)', () => { + beforeEach(() => { + process.env['KIMI_CODE_EXPERIMENTAL_FLAG'] = '1'; + }); + + afterEach(() => { + delete process.env['KIMI_CODE_EXPERIMENTAL_FLAG']; + delete process.env['KIMI_LOOP_MAX_RETRIES_PER_STEP']; + delete process.env['KIMI_LOOP_MAX_ATTEMPTS_PER_STEP']; + }); + + it('accepts a config valid for the v2 engine, including schema-less keys', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +default_model = "kimi" + +[providers.kimi] +type = "kimi" +base_url = "https://api.example.com/v1" +api_key = "YOUR_API_KEY" + +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = 262144 +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(stdout.join('')).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + }); + + it('reports schema-invalid sections with TOML-style field paths', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[models.kimi] +provider = "kimi" +model = "kimi" +max_context_size = "large" +`, + 'utf-8', + ); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Validation issues:'); + expect(err).toContain('models.kimi.max_context_size:'); + }); + + it('warns about unknown top-level keys without failing', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[providrs.kimi] +type = "kimi" +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + expect(out).toContain('Unknown top-level key ignored by the v2 engine: providrs.'); + }); + + it('reports TOML syntax errors with line and column', async () => { + await writeFile(join(dir, 'config.toml'), '[providers.kimi\ntype = "kimi"\n', 'utf-8'); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Invalid TOML in'); + expect(err).toMatch(/\(line \d+, column \d+\)/); + }); + + it('warns about deprecated config keys without failing', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[loop_control] +max_retries_per_step = 3 +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + expect(out).toContain("'max_retries_per_step' is deprecated"); + expect(out).toContain("rename it to 'max_attempts_per_step'"); + }); + + it('warns about a deprecated env var that supplies a value', async () => { + await writeFile(join(dir, 'config.toml'), '[loop_control]\n', 'utf-8'); + process.env['KIMI_LOOP_MAX_RETRIES_PER_STEP'] = '5'; + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(stdout.join('')).toContain( + 'Environment variable KIMI_LOOP_MAX_RETRIES_PER_STEP is deprecated; use KIMI_LOOP_MAX_ATTEMPTS_PER_STEP instead.', + ); + }); + + it('does not warn about the deprecated env var when the primary one is set', async () => { + await writeFile(join(dir, 'config.toml'), '[loop_control]\n', 'utf-8'); + process.env['KIMI_LOOP_MAX_RETRIES_PER_STEP'] = '5'; + process.env['KIMI_LOOP_MAX_ATTEMPTS_PER_STEP'] = '5'; + const { deps, stdout } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stdout.join('')).not.toContain('KIMI_LOOP_MAX_RETRIES_PER_STEP'); + }); +}); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 8f0c17d9d7f..5f0dc3f04d0 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -595,7 +595,7 @@ describe('runShell', () => { }); }); - it('forwards config.toml diagnostics as startup notices', async () => { + it('leaves config.toml diagnostics to the TUI instead of the startup notice', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', editorCommand: null, @@ -623,9 +623,12 @@ describe('runShell', () => { '1.2.3-test', ); + // Diagnostics render in warning yellow via `showConfigWarningsIfAny` at + // `finishStartup`; the (dim) startup notice stays reserved for things like + // tui.toml parse errors, so the same warning is not shown twice. const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ - startupNotice: 'Ignored invalid config in config.toml: loop_control.', + startupNotice: undefined, }); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 23633b29396..08ff88e9993 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -61,7 +61,7 @@ effort = "high" keep = "all" [loop_control] -max_retries_per_step = 10 +max_attempts_per_step = 10 reserved_context_size = 50000 [background] @@ -235,18 +235,20 @@ When the experiment is enabled, the configuration is validated as the session st | --- | --- | --- | | `default_thinking` | 0.21.0 | Top-level boolean, replaced by `[thinking] enabled`. Migrate `default_thinking = true` to `enabled = true`, and `default_thinking = false` to `enabled = false`. | | `thinking.mode` | 0.21.0 | One of `auto` / `on` / `off`, replaced by `[thinking] enabled`. `mode = "off"` becomes `enabled = false`; `mode = "on"` and `mode = "auto"` are equivalent to `enabled = true` (the default) and can be removed. | +| `loop_control.max_retries_per_step` | 0.32.0 | Replaced by `loop_control.max_attempts_per_step` (the value was always a total-attempt limit, including the first try). The old key is ignored and reports a warning on startup; rename it in `config.toml`. | +| `loop_control.max_steps_per_run` | 0.32.0 | Replaced by `loop_control.max_steps_per_turn`. The old key is ignored and reports a warning on startup; rename it in `config.toml`. | ## `loop_control` -`loop_control` governs the step count limit, per-step retry count, and the threshold that triggers automatic context compaction in the Agent execution loop. +`loop_control` governs the step count limit, the per-step attempt limit, and the threshold that triggers automatic context compaction in the Agent execution loop. | Field | Type | Default | Description | | --- | --- | --- | --- | | `max_steps_per_turn` | `integer` | — | Maximum steps per turn; unset or `0` means unlimited | -| `max_retries_per_step` | `integer` | `10` | Maximum retries after a step failure | +| `max_attempts_per_step` | `integer` | `10` | Maximum total attempts for a failing step, including the initial attempt | | `reserved_context_size` | `integer` | — | Number of tokens reserved for model output; automatic compaction is triggered when the remaining context window falls below this value | -`max_steps_per_turn` can be overridden by the `KIMI_LOOP_MAX_STEPS_PER_TURN` environment variable, and `max_retries_per_step` by `KIMI_LOOP_MAX_RETRIES_PER_STEP`; both take higher priority than the config file. +`max_steps_per_turn` can be overridden by the `KIMI_LOOP_MAX_STEPS_PER_TURN` environment variable, and `max_attempts_per_step` by `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP`; both take higher priority than the config file. The former `KIMI_LOOP_MAX_RETRIES_PER_STEP` variable is deprecated but still honored (with a startup warning) when the new one is unset. Retries only apply to transient failures — connection errors, timeouts, HTTP 429 rate limits, and 5xx server errors. A 429 caused by an exhausted quota or insufficient account balance is not retried and fails immediately, since it cannot succeed until the account is recharged. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 50e3c968984..bf86651c00d 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -134,7 +134,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | -| `KIMI_LOOP_MAX_RETRIES_PER_STEP` | Maximum retries after a step failure; takes higher priority than `[loop_control] max_retries_per_step` in `config.toml` (default `10`) | Non-negative integer; invalid values are ignored | +| `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` | Maximum total attempts for a failing step (including the initial attempt); takes higher priority than `[loop_control] max_attempts_per_step` in `config.toml` (default `10`). The deprecated `KIMI_LOOP_MAX_RETRIES_PER_STEP` is still honored with a warning when this variable is unset | Non-negative integer; invalid values are ignored | | `KIMI_WEB_SEARCH_BASE_URL` | API URL of the web search (`WebSearch`) service; takes higher priority than `[services.moonshot_search] base_url` in `config.toml`, and enables the service without that config section. Persisted credentials and custom headers are not forwarded to an env-selected endpoint | Non-blank string; blank values are ignored | | `KIMI_WEB_SEARCH_API_KEY` | API key of the web search (`WebSearch`) service; replaces both the configured API key and OAuth credential when set | Non-blank string; blank values are ignored | | `KIMI_WEB_FETCH_BASE_URL` | API URL of the web fetch (`FetchURL`) service; takes higher priority than `[services.moonshot_fetch] base_url`. Persisted credentials and custom headers are not forwarded to an env-selected endpoint. Without an env or config endpoint, signed-in users try the managed Kimi OAuth fetch service before direct local requests | Non-blank string; blank values are ignored | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index aaeea89b255..e210d47e0af 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -61,7 +61,7 @@ effort = "high" keep = "all" [loop_control] -max_retries_per_step = 10 +max_attempts_per_step = 10 reserved_context_size = 50000 [background] @@ -235,18 +235,20 @@ max_output_size = 8192 | --- | --- | --- | | `default_thinking` | 0.21.0 | 顶层布尔值,由 `[thinking] enabled` 取代。将 `default_thinking = true` 迁移为 `enabled = true`,`default_thinking = false` 迁移为 `enabled = false`。 | | `thinking.mode` | 0.21.0 | 可选值 `auto` / `on` / `off`,由 `[thinking] enabled` 取代。`mode = "off"` 改为 `enabled = false`;`mode = "on"` 和 `mode = "auto"` 等价于 `enabled = true`(默认值),可删除该行。 | +| `loop_control.max_retries_per_step` | 0.32.0 | 由 `loop_control.max_attempts_per_step` 取代(该值本来就是含首次尝试的总尝试次数上限)。旧 key 不再生效,启动时会给出警告,请在 `config.toml` 中手动改名。 | +| `loop_control.max_steps_per_run` | 0.32.0 | 由 `loop_control.max_steps_per_turn` 取代。旧 key 不再生效,启动时会给出警告,请在 `config.toml` 中手动改名。 | ## `loop_control` -`loop_control` 控制 Agent 执行循环的步数上限、单步重试次数,以及触发上下文自动压缩的阈值。 +`loop_control` 控制 Agent 执行循环的步数上限、单步尝试次数上限,以及触发上下文自动压缩的阈值。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `max_steps_per_turn` | `integer` | — | 单轮最大步数;不设或设为 `0` 则无上限 | -| `max_retries_per_step` | `integer` | `10` | 单步失败后的最大重试次数 | +| `max_attempts_per_step` | `integer` | `10` | 单步失败后的最大总尝试次数(含首次尝试) | | `reserved_context_size` | `integer` | — | 预留给模型输出的 token 数;上下文窗口剩余量低于此值时触发自动压缩 | -`max_steps_per_turn` 可被环境变量 `KIMI_LOOP_MAX_STEPS_PER_TURN` 覆盖,`max_retries_per_step` 可被 `KIMI_LOOP_MAX_RETRIES_PER_STEP` 覆盖,优先级均高于配置文件。 +`max_steps_per_turn` 可被环境变量 `KIMI_LOOP_MAX_STEPS_PER_TURN` 覆盖,`max_attempts_per_step` 可被 `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` 覆盖,优先级均高于配置文件。旧的 `KIMI_LOOP_MAX_RETRIES_PER_STEP` 已废弃,但在新变量未设置时仍生效(启动时会给出警告)。 重试仅针对瞬时故障——连接错误、超时、HTTP 429 限流和 5xx 服务端错误。账户额度耗尽或余额不足导致的 429 不会重试,会立即失败:在充值之前重试不可能成功。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 40a31d6c31b..b127e525b84 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -134,7 +134,7 @@ kimi | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 | -| `KIMI_LOOP_MAX_RETRIES_PER_STEP` | 单步失败后的最大重试次数;优先级高于 `config.toml` 的 `[loop_control] max_retries_per_step`(默认 `10`) | 非负整数;非法值被忽略 | +| `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP` | 单步失败后的最大总尝试次数(含首次尝试);优先级高于 `config.toml` 的 `[loop_control] max_attempts_per_step`(默认 `10`)。旧的 `KIMI_LOOP_MAX_RETRIES_PER_STEP` 已废弃,但在本变量未设置时仍生效并给出警告 | 非负整数;非法值被忽略 | | `KIMI_WEB_SEARCH_BASE_URL` | 网页搜索(`WebSearch`)服务的 API URL;优先级高于 `config.toml` 的 `[services.moonshot_search] base_url`,未写配置段时也可启用服务。文件中持久化的凭据和自定义 header 不会发送到环境变量指定的端点 | 非空字符串;空白值被忽略 | | `KIMI_WEB_SEARCH_API_KEY` | 网页搜索(`WebSearch`)服务的 API 密钥;设置后同时替换配置中的 API 密钥和 OAuth 凭据 | 非空字符串;空白值被忽略 | | `KIMI_WEB_FETCH_BASE_URL` | 网页抓取(`FetchURL`)服务的 API URL;优先级高于 `[services.moonshot_fetch] base_url`。文件中持久化的凭据和自定义 header 不会发送到环境变量指定的端点。环境变量和配置都没有指定端点时,已登录用户会先尝试 Kimi OAuth 托管抓取服务,再回退到本地直接请求 | 非空字符串;空白值被忽略 | diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 2fdf45d6d0d..3be81759ea1 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -152,15 +152,18 @@ extra_skill_dirs = [] # loopControl (config.toml: loop_control) # owner: src/agent/loop/configSection.ts # scope: core -# hooks: custom fromToml · custom toToml · stripEnv +# hooks: custom toToml · stripEnv +# deprecations (old key is ignored + warns; rename manually): +# max_retries_per_step -> max_attempts_per_step +# max_steps_per_run -> max_steps_per_turn # env: # max_steps_per_turn <- KIMI_LOOP_MAX_STEPS_PER_TURN (custom parse) -# max_retries_per_step <- KIMI_LOOP_MAX_RETRIES_PER_STEP (custom parse) +# max_attempts_per_step <- KIMI_LOOP_MAX_ATTEMPTS_PER_STEP (custom parse; deprecated fallback KIMI_LOOP_MAX_RETRIES_PER_STEP) # ########################################################################## [loop_control] # max_steps_per_turn: integer -# max_retries_per_step: integer +# max_attempts_per_step: integer # max_ralph_iterations: integer # reserved_context_size: integer # compaction_trigger_ratio: number diff --git a/packages/agent-core-v2/scripts/gen-config-manifest.mts b/packages/agent-core-v2/scripts/gen-config-manifest.mts index 01662774d15..5853bce2157 100644 --- a/packages/agent-core-v2/scripts/gen-config-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-config-manifest.mts @@ -119,6 +119,7 @@ interface EnvRow { /** Property access shape of an `EnvBinding` object (avoids index-signature access). */ interface EnvBindingFields { readonly env?: unknown; + readonly deprecatedEnv?: unknown; readonly parse?: unknown; readonly default?: unknown; } @@ -133,6 +134,9 @@ function flattenEnvBindings(bindings: unknown, path: string[] = []): EnvRow[] { const detail: string[] = []; if (binding.parse !== undefined) detail.push('custom parse'); if (binding.default !== undefined) detail.push(`default ${JSON.stringify(binding.default)}`); + if (typeof binding.deprecatedEnv === 'string') { + detail.push(`deprecated fallback ${binding.deprecatedEnv}`); + } return [{ field: path.join('.'), env: binding.env, detail: detail.join('; ') }]; } return Object.entries(bindings).flatMap(([key, value]) => flattenEnvBindings(value, [...path, key])); @@ -278,6 +282,13 @@ function renderSection(section: ConfigSectionContribution, owner: string | undef if (options.toToml !== undefined) hooks.push('custom toToml'); if (options.stripEnv !== undefined) hooks.push('stripEnv'); if (hooks.length > 0) lines.push(`# hooks: ${hooks.join(' · ')}`); + const deprecations = options.deprecations ?? []; + if (deprecations.length > 0) { + lines.push('# deprecations (old key is ignored + warns; rename manually):'); + for (const deprecation of deprecations) { + lines.push(`# ${deprecation.key} -> ${deprecation.replacement}`); + } + } const envRows = flattenEnvBindings(options.env); if (envRows.length > 0) { lines.push('# env:'); diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts index 0c0cc76737f..d169a483057 100644 --- a/packages/agent-core-v2/src/agent/loop/configSection.ts +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -3,12 +3,17 @@ * TOML transforms. * * Owns the `[loop_control]` configuration section (step / retry / context-size - * limits), plus the snake_case ↔ camelCase TOML transforms (including - * the legacy `max_steps_per_run` → `maxStepsPerTurn` rename). The step and retry - * budgets also accept operational env overrides (`KIMI_LOOP_MAX_STEPS_PER_TURN` - * / `KIMI_LOOP_MAX_RETRIES_PER_STEP`); `config` resolves each field as - * `env > config.toml > default` and re-applies the env binding on every read. - * Self-registered at module load via `registerConfigSection`. + * limits). Renamed keys are declared through the config domain's deprecation + * mechanism (`deprecations`): a deprecated key in `config.toml` no longer + * applies and reports a warning pointing at its replacement — this covers the + * `max_retries_per_step` → `max_attempts_per_step` rename and the older + * `max_steps_per_run` → `max_steps_per_turn` one. The step and retry budgets + * also accept operational env overrides (`KIMI_LOOP_MAX_STEPS_PER_TURN` / + * `KIMI_LOOP_MAX_ATTEMPTS_PER_STEP`; the former + * `KIMI_LOOP_MAX_RETRIES_PER_STEP` still resolves as a deprecated fallback + * with a warning); `config` resolves each field as `env > config.toml > + * default` and re-applies the env binding on every read. Self-registered at + * module load via `registerConfigSection`. * * While a field's env var is set, `stripEnvBoundFields` restores its env-free * raw value before `set`/`replace` persists, so an env override echoed @@ -19,16 +24,18 @@ import { z } from 'zod'; import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; -import { plainObjectToToml, transformPlainObject } from '#/app/config/toml'; +import { plainObjectToToml } from '#/app/config/toml'; export const LOOP_CONTROL_SECTION = 'loopControl'; export const LOOP_MAX_STEPS_PER_TURN_ENV = 'KIMI_LOOP_MAX_STEPS_PER_TURN'; +export const LOOP_MAX_ATTEMPTS_PER_STEP_ENV = 'KIMI_LOOP_MAX_ATTEMPTS_PER_STEP'; +/** Deprecated former name of {@link LOOP_MAX_ATTEMPTS_PER_STEP_ENV}. */ export const LOOP_MAX_RETRIES_PER_STEP_ENV = 'KIMI_LOOP_MAX_RETRIES_PER_STEP'; export const LoopControlSchema = z.object({ maxStepsPerTurn: z.number().int().min(0).optional(), - maxRetriesPerStep: z.number().int().min(0).optional(), + maxAttemptsPerStep: z.number().int().min(0).optional(), maxRalphIterations: z.number().int().min(-1).optional(), reservedContextSize: z.number().int().min(0).optional(), compactionTriggerRatio: z.number().min(0.5).max(0.99).optional(), @@ -45,29 +52,26 @@ function parseNonNegativeInt(raw: string): number | undefined { export const loopControlEnvBindings: EnvBindings = envBindings(LoopControlSchema, { maxStepsPerTurn: { env: LOOP_MAX_STEPS_PER_TURN_ENV, parse: parseNonNegativeInt }, - maxRetriesPerStep: { env: LOOP_MAX_RETRIES_PER_STEP_ENV, parse: parseNonNegativeInt }, + maxAttemptsPerStep: { + env: LOOP_MAX_ATTEMPTS_PER_STEP_ENV, + deprecatedEnv: LOOP_MAX_RETRIES_PER_STEP_ENV, + parse: parseNonNegativeInt, + }, }); export const stripLoopControlEnv = stripEnvBoundFields(loopControlEnvBindings); -export const loopControlFromToml = (rawSnake: unknown): unknown => { - if (rawSnake === null || typeof rawSnake !== 'object' || Array.isArray(rawSnake)) return rawSnake; - const out = transformPlainObject(rawSnake as Record); - if (out['maxStepsPerTurn'] === undefined && out['maxStepsPerRun'] !== undefined) { - out['maxStepsPerTurn'] = out['maxStepsPerRun']; - } - delete out['maxStepsPerRun']; - return out; -}; - export const loopControlToToml = (value: unknown, rawSnake: unknown): unknown => { if (value === null || typeof value !== 'object' || Array.isArray(value)) return value; return plainObjectToToml(value as Record, rawSnake); }; registerConfigSection(LOOP_CONTROL_SECTION, LoopControlSchema, { - fromToml: loopControlFromToml, toToml: loopControlToToml, env: loopControlEnvBindings, stripEnv: stripLoopControlEnv, + deprecations: [ + { key: 'max_retries_per_step', replacement: 'max_attempts_per_step' }, + { key: 'max_steps_per_run', replacement: 'max_steps_per_turn' }, + ], }); diff --git a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts index c2f4fb9bbe7..70cb2aa6fec 100644 --- a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts +++ b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts @@ -127,7 +127,7 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry this.failedAttempts += 1; const maxAttempts = Math.max( - this.config.get(LOOP_CONTROL_SECTION)?.maxRetriesPerStep ?? + this.config.get(LOOP_CONTROL_SECTION)?.maxAttemptsPerStep ?? DEFAULT_MAX_RETRY_ATTEMPTS, 1, ); diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index f51d61259b4..c0d668b7d0f 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -14,13 +14,18 @@ * binding's `parse` is ignored. `stripEnvBoundFields` builds the matching * write guard for persistable env-bound fields: while a field's env var * resolves to a value, `set`/`replace` restores the field's value from the - * env-free raw base (already `fromToml`-normalized, so legacy key renames are - * honored) — or drops it when absent there — instead of persisting an echoed - * env value; otherwise writes pass through untouched. When nothing + * env-free raw base (already `fromToml`-normalized) — or drops it when absent + * there — instead of persisting an echoed env value; otherwise writes pass + * through untouched. When nothing * persistable remains, the write is a no-op for the section — the env-free * raw base is kept as-is (unknown forward-compatible fields survive repeated * stripped writes) — and the section is cleared only when the base is empty, * so registered defaults keep applying. + * + * Sections declare key renames through `deprecations` and env-var renames + * through a binding's `deprecatedEnv`: a deprecated TOML key is ignored (its + * value no longer applies) and a deprecated env var still resolves as a + * fallback; both surface warning `ConfigDiagnostic`s while in use. */ import type { Event } from '#/_base/event'; @@ -38,10 +43,29 @@ export type EnvBinding = | string | { readonly env: string; + /** + * Deprecated former name of `env`. Still honored (with a deprecation + * warning) when `env` itself is absent or fails to parse, so existing + * setups keep working until the user renames the variable. + */ + readonly deprecatedEnv?: string; readonly parse?: (raw: string) => unknown; readonly default?: unknown; }; +/** + * A declared config-key rename: `key` (snake_case, as written on disk) is + * deprecated in favor of `replacement`. While the old key is present in the + * user's config file the service reports a warning diagnostic; the old value + * is NOT honored — only `replacement` (or the section default) applies. + */ +export interface ConfigKeyDeprecation { + readonly key: string; + readonly replacement: string; + /** Optional extra guidance appended to the generated warning message. */ + readonly message?: string; +} + export type EnvBindings = EnvBinding | { [K in keyof T]?: EnvBinding | EnvBindings }; export type AnyEnvBindings = EnvBinding | { readonly [key: string]: EnvBinding | AnyEnvBindings }; @@ -68,10 +92,7 @@ export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv let out: Record | undefined; for (const [field, binding] of Object.entries(bindings)) { if (binding === undefined || !isEnvBinding(binding)) continue; - const rawEnv = getEnv(typeof binding === 'string' ? binding : binding.env); - if (rawEnv === undefined) continue; - const parse = typeof binding === 'string' ? undefined : binding.parse; - if (parse !== undefined && parse(rawEnv) === undefined) continue; + if (!resolvesFromEnv(binding, getEnv)) continue; out ??= { ...(value as Record) }; if (base[field] !== undefined) { out[field] = base[field]; @@ -85,6 +106,25 @@ export function stripEnvBoundFields(bindings: EnvBindings): ConfigStripEnv }; } +/** + * Whether a leaf binding currently resolves from the environment: the primary + * var wins when set and parseable, then the deprecated fallback (same rule as + * the read path in `configService`'s `resolveBinding`). + */ +function resolvesFromEnv(binding: EnvBinding, getEnv: (name: string) => string | undefined): boolean { + const parse = typeof binding === 'string' ? undefined : binding.parse; + const names = + typeof binding === 'string' + ? [binding] + : binding.deprecatedEnv === undefined + ? [binding.env] + : [binding.env, binding.deprecatedEnv]; + return names.some((name) => { + const raw = getEnv(name); + return raw !== undefined && (parse === undefined || parse(raw) !== undefined); + }); +} + export type ConfigFromToml = (rawSnake: unknown) => unknown; export type ConfigToToml = (value: unknown, rawSnake: unknown) => unknown; @@ -99,6 +139,7 @@ export interface ConfigSection { readonly stripEnv?: ConfigStripEnv; readonly fromToml?: ConfigFromToml; readonly toToml?: ConfigToToml; + readonly deprecations?: readonly ConfigKeyDeprecation[]; } export interface RegisterSectionOptions { @@ -109,6 +150,7 @@ export interface RegisterSectionOptions { readonly stripEnv?: ConfigStripEnv; readonly fromToml?: ConfigFromToml; readonly toToml?: ConfigToToml; + readonly deprecations?: readonly ConfigKeyDeprecation[]; } export interface ConfigEffectiveOverlay { @@ -198,6 +240,12 @@ export interface IConfigService { readonly ready: Promise; readonly onDidChangeConfiguration: Event; readonly onDidSectionChange: Event; + /** + * Fired when the diagnostics list changes (load / reload / env overlay + * re-application), carrying the full current list — including an empty + * list when the last diagnostic clears. + */ + readonly onDidChangeDiagnostics: Event; get(domain: string): T; inspect(domain: string): ConfigInspectValue; getAll(): ResolvedConfig; diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 8546c80c9bd..56ea4d19e08 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -19,7 +19,13 @@ * `bootstrap`, persists the TOML document through the `storage` TOML * atomic-document store (reloading when the document changes on disk), and logs * through `log`. Late section / overlay registration re-validates the - * already-loaded raw value and re-runs overlays. Bound at App scope. + * already-loaded raw value and re-runs overlays. Section-declared key + * `deprecations` are detected from the on-disk document on every load and + * reported as warning diagnostics (the deprecated value is NOT applied, and + * the file is never rewritten); env-var renames declared via a binding's + * `deprecatedEnv` still resolve as a fallback, likewise with a warning. + * Diagnostics changes are published through `onDidChangeDiagnostics`. Bound + * at App scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -57,6 +63,7 @@ import { import { deepEqual, deepMerge, describeUnknownError, isPlainObject } from './configPure'; import { getConfigSectionContributions } from './configSectionContributions'; import { getConfigOverlayContributions } from './configOverlayContributions'; +import { collectKeyDeprecations } from './deprecations'; import { migrateThinkingEffortMaxToHigh } from './migrations'; import { applySectionToToml, @@ -71,15 +78,42 @@ const CONFIG_SCOPE = ''; type GetEnv = (name: string) => string | undefined; +/** Reports a deprecated env var actually supplying a value: (oldName, newName). */ +type OnDeprecatedEnv = (oldName: string, newName: string) => void; + function isEnvBinding(value: unknown): value is EnvBinding { return typeof value === 'string' || (isPlainObject(value) && 'env' in value); } -function resolveBinding(binding: EnvBinding, getEnv: GetEnv, existing: unknown): unknown { - const envName = typeof binding === 'string' ? binding : binding.env; - const raw = getEnv(envName); - if (raw !== undefined) { - return typeof binding === 'string' ? raw : binding.parse ? binding.parse(raw) : raw; +function parseBoundRaw(binding: EnvBinding, raw: string): unknown { + return typeof binding === 'string' ? raw : binding.parse ? binding.parse(raw) : raw; +} + +function resolveBinding( + binding: EnvBinding, + getEnv: GetEnv, + existing: unknown, + onDeprecatedEnv?: OnDeprecatedEnv, +): unknown { + if (typeof binding !== 'string') { + const raw = getEnv(binding.env); + if (raw !== undefined) { + const parsed = parseBoundRaw(binding, raw); + if (parsed !== undefined) return parsed; + } + if (binding.deprecatedEnv !== undefined) { + const deprecatedRaw = getEnv(binding.deprecatedEnv); + if (deprecatedRaw !== undefined) { + const parsed = parseBoundRaw(binding, deprecatedRaw); + if (parsed !== undefined) { + onDeprecatedEnv?.(binding.deprecatedEnv, binding.env); + return parsed; + } + } + } + } else { + const raw = getEnv(binding); + if (raw !== undefined) return raw; } if (typeof binding === 'object' && binding.default !== undefined && existing === undefined) { return binding.default; @@ -91,17 +125,18 @@ function applyEnvBindings( target: Record, bindings: AnyEnvBindings, getEnv: GetEnv, + onDeprecatedEnv?: OnDeprecatedEnv, ): void { for (const [key, binding] of Object.entries(bindings)) { if (isEnvBinding(binding)) { - const resolved = resolveBinding(binding, getEnv, target[key]); + const resolved = resolveBinding(binding, getEnv, target[key], onDeprecatedEnv); if (resolved !== undefined) target[key] = resolved; } else if (binding !== undefined) { const child: Record = isPlainObject(target[key]) ? { ...target[key] } : {}; target[key] = child; - applyEnvBindings(child, binding as AnyEnvBindings, getEnv); + applyEnvBindings(child, binding as AnyEnvBindings, getEnv, onDeprecatedEnv); if (Object.keys(child).length === 0) { delete target[key]; } @@ -109,12 +144,17 @@ function applyEnvBindings( } } -function applySectionEnv(base: unknown, env: AnyEnvBindings, getEnv: GetEnv): unknown { +function applySectionEnv( + base: unknown, + env: AnyEnvBindings, + getEnv: GetEnv, + onDeprecatedEnv?: OnDeprecatedEnv, +): unknown { if (isEnvBinding(env)) { - return resolveBinding(env, getEnv, base); + return resolveBinding(env, getEnv, base, onDeprecatedEnv); } const target: Record = isPlainObject(base) ? { ...base } : {}; - applyEnvBindings(target, env, getEnv); + applyEnvBindings(target, env, getEnv, onDeprecatedEnv); return target; } @@ -131,7 +171,8 @@ function isSameSection( existing.stripEnv === (options.stripEnv as ConfigSection['stripEnv']) && existing.fromToml === options.fromToml && existing.toToml === options.toToml && - deepEqual(existing.defaultValue, options.defaultValue) + deepEqual(existing.defaultValue, options.defaultValue) && + deepEqual(existing.deprecations, options.deprecations) ); } @@ -183,6 +224,7 @@ export class ConfigRegistry implements IConfigRegistry { stripEnv: options.stripEnv as ConfigSection['stripEnv'], fromToml: options.fromToml, toToml: options.toToml, + deprecations: options.deprecations, }); this._onDidRegisterSection.fire({ domain }); } @@ -225,6 +267,11 @@ export class ConfigService extends Disposable implements IConfigService { readonly onDidChangeConfiguration: Event = this._onDidChangeConfiguration.event; private readonly _onDidSectionChange = this._register(new Emitter()); readonly onDidSectionChange: Event = this._onDidSectionChange.event; + private readonly _onDidChangeDiagnostics = this._register( + new Emitter(), + ); + readonly onDidChangeDiagnostics: Event = + this._onDidChangeDiagnostics.event; readonly ready: Promise; private stateChain: Promise = Promise.resolve(); @@ -236,6 +283,7 @@ export class ConfigService extends Disposable implements IConfigService { private memory: ResolvedConfig = {}; private delivered: ResolvedConfig = {}; private readonly diagnosticsList: ConfigDiagnostic[] = []; + private lastDiagnosticsSnapshot = '[]'; private readonly configKey: string; constructor( @@ -293,6 +341,24 @@ export class ConfigService extends Disposable implements IConfigService { return [...this.diagnosticsList]; } + /** Append a diagnostic, skipping exact duplicates (rebuilds re-run the same checks). */ + private pushDiagnostic(diagnostic: ConfigDiagnostic): void { + const duplicate = this.diagnosticsList.some( + (existing) => + existing.domain === diagnostic.domain && + existing.severity === diagnostic.severity && + existing.message === diagnostic.message, + ); + if (!duplicate) this.diagnosticsList.push(diagnostic); + } + + private emitDiagnosticsIfChanged(): void { + const snapshot = JSON.stringify(this.diagnosticsList); + if (snapshot === this.lastDiagnosticsSnapshot) return; + this.lastDiagnosticsSnapshot = snapshot; + this._onDidChangeDiagnostics.fire(this.diagnostics()); + } + async set( domain: string, patch: unknown, @@ -436,11 +502,25 @@ export class ConfigService extends Disposable implements IConfigService { error instanceof TomlError ? `Failed to parse ${this.bootstrap.configPath}: ${describeTomlSyntaxError(error)}` : describeUnknownError(error); - this.diagnosticsList.push({ severity: 'error', message }); + this.pushDiagnostic({ severity: 'error', message }); this.log.warn('config load failed', { error: describeUnknownError(error) }); } const nextRawSnake = cloneRecord(fileData); + // Key-deprecation warnings derive from the on-disk document, so collect + // them before the unchanged-file early return — the list was just cleared + // above and a no-op reload must not drop them. + for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) { + this.pushDiagnostic(diagnostic); + } if (source !== 'load' && JSON.stringify(nextRawSnake) === JSON.stringify(this.rawSnake)) { + // The file is unchanged, so values and change events stay as they are — + // but env-derived diagnostics (deprecated env fallbacks, overlay + // failures) were cleared above and must be recollected over a scratch + // copy, or a no-op reload would silently drop them. + const scratch = { ...this.validated }; + this.applySectionEnvBindings(scratch, true); + this.applyEnvOverlay(scratch); + this.emitDiagnosticsIfChanged(); return; } this.rawSnake = nextRawSnake; @@ -466,6 +546,7 @@ export class ConfigService extends Disposable implements IConfigService { if (!deepEqual(previous[domain], next[domain])) candidates.add(domain); } this.commit(source, [...candidates]); + this.emitDiagnosticsIfChanged(); } private deliveredValue(domain: string): unknown { @@ -492,7 +573,7 @@ export class ConfigService extends Disposable implements IConfigService { try { validated[domain] = this.registry.validate(domain, value); } catch (error) { - this.diagnosticsList.push({ + this.pushDiagnostic({ domain, severity: 'warning', message: `Ignored invalid config section '${domain}': ${describeUnknownError(error)}`, @@ -513,11 +594,20 @@ export class ConfigService extends Disposable implements IConfigService { if (section.env === undefined) continue; try { const base = effective[section.domain]; - const next = applySectionEnv(base, section.env, getEnv); + const onDeprecatedEnv: OnDeprecatedEnv | undefined = reportErrors + ? (oldName, newName) => { + this.pushDiagnostic({ + domain: section.domain, + severity: 'warning', + message: `Environment variable ${oldName} is deprecated; use ${newName} instead.`, + }); + } + : undefined; + const next = applySectionEnv(base, section.env, getEnv, onDeprecatedEnv); effective[section.domain] = this.registry.validate(section.domain, next); } catch (error) { if (reportErrors) { - this.diagnosticsList.push({ + this.pushDiagnostic({ domain: section.domain, severity: 'warning', message: `Ignoring env overlay for '${section.domain}': ${describeUnknownError(error)}`, @@ -536,7 +626,7 @@ export class ConfigService extends Disposable implements IConfigService { overlay.apply(effective, getEnv, validate); } catch (error) { if (reportErrors) { - this.diagnosticsList.push({ + this.pushDiagnostic({ severity: 'warning', message: `Ignoring config environment overlay: ${describeUnknownError(error)}`, }); @@ -553,6 +643,7 @@ export class ConfigService extends Disposable implements IConfigService { this.applyEnvOverlay(next); this.effective = next; this.commit('reload', [...new Set([...Object.keys(before), ...Object.keys(next)])]); + this.emitDiagnosticsIfChanged(); } private revalidateDomain(domain: string): void { @@ -585,10 +676,17 @@ export class ConfigService extends Disposable implements IConfigService { if (section.env !== undefined) { const getEnv = (name: string): string | undefined => this.bootstrap.getEnv(name); try { - const next = applySectionEnv(this.effective[domain], section.env, getEnv); + const onDeprecatedEnv: OnDeprecatedEnv = (oldName, newName) => { + this.pushDiagnostic({ + domain, + severity: 'warning', + message: `Environment variable ${oldName} is deprecated; use ${newName} instead.`, + }); + }; + const next = applySectionEnv(this.effective[domain], section.env, getEnv, onDeprecatedEnv); this.effective[domain] = this.registry.validate(domain, next); } catch (error) { - this.diagnosticsList.push({ + this.pushDiagnostic({ domain, severity: 'warning', message: `Ignoring env overlay for '${domain}': ${describeUnknownError(error)}`, @@ -596,6 +694,7 @@ export class ConfigService extends Disposable implements IConfigService { } } this.commit('reload', [domain]); + this.emitDiagnosticsIfChanged(); } private async persist(domain: string): Promise { diff --git a/packages/agent-core-v2/src/app/config/deprecations.ts b/packages/agent-core-v2/src/app/config/deprecations.ts new file mode 100644 index 00000000000..fa42847d80d --- /dev/null +++ b/packages/agent-core-v2/src/app/config/deprecations.ts @@ -0,0 +1,41 @@ +/** + * `config` domain — declarative config-key deprecation detection. + * + * A section declares its renames once (`RegisterSectionOptions.deprecations`, + * snake_case keys as written on disk) and this module turns the presence of a + * deprecated key in the on-disk document into a warning `ConfigDiagnostic`. + * Detection is read-only: the old value is never mapped onto the new key (the + * section schema no longer knows the old key, so it is dropped at validation), + * and the user's file is left untouched — the warning is the migration guide. + */ + +import type { ConfigDiagnostic, ConfigSection } from './config'; +import { isPlainObject } from './configPure'; +import { camelToSnake } from './toml'; + +export function collectKeyDeprecations( + rawSnake: Record, + sections: readonly ConfigSection[], +): ConfigDiagnostic[] { + const diagnostics: ConfigDiagnostic[] = []; + for (const section of sections) { + const deprecations = section.deprecations; + if (deprecations === undefined || deprecations.length === 0) continue; + const snakeDomain = camelToSnake(section.domain); + const rawSection = rawSnake[snakeDomain]; + if (!isPlainObject(rawSection)) continue; + for (const deprecation of deprecations) { + if (rawSection[deprecation.key] === undefined) continue; + diagnostics.push({ + domain: section.domain, + severity: 'warning', + message: + `[${snakeDomain}] '${deprecation.key}' is deprecated and no longer used; ` + + `rename it to '${deprecation.replacement}'.` + + (deprecation.message === undefined ? '' : ` ${deprecation.message}`) + + ' Run /update-config to fix it.', + }); + } + } + return diagnostics; +} diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md index 85901142442..15583877486 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/update-config.md @@ -1,6 +1,6 @@ --- name: update-config -description: Inspect or edit kimi-code's own config — `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update). Use when the user asks what a setting does or wants to change one. +description: Inspect or edit kimi-code's own config — `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update). Use when the user asks what a setting does, wants to change one, or needs to fix a deprecated config key / environment variable warning. --- # Configure kimi-code (update-config) @@ -97,6 +97,13 @@ Once local validation passes, tell the user how to make the change take effect Note: `/reload` is available **only when idle** — if a reply is streaming, press Esc / Ctrl-C to stop first. `kimi doctor` already validated the schema before the overwrite, so reload should apply cleanly; if it still errors, follow the message to fix it or recover from the most recent timestamped backup. If you don't want to reload now, the **next new session** picks it up automatically. +## Capability 5: fix a deprecated key or env-var warning + +kimi reports configuration deprecations as warnings — in the TUI startup notices and pushed to clients as the `event.config.warning` event. There are two shapes, handled differently: + +- **Deprecated TOML key** — e.g. `[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'.` The old value no longer applies, so fix it promptly: follow the Capability 2 flow (copy → Edit → validate → back up → overwrite) and **rename the key in `config.toml`, keeping its value unchanged**. The warning names the exact section and replacement key — use those; never guess other renames. After `/reload`, the warning disappears. +- **Deprecated environment variable** — e.g. `Environment variable KIMI_LOOP_MAX_RETRIES_PER_STEP is deprecated; use KIMI_LOOP_MAX_ATTEMPTS_PER_STEP instead.` The old variable still works, but this is **not** fixable by editing `config.toml`/`tui.toml` — tell the user to rename the variable where they set it (shell profile, CI environment, launcher script). Do not add anything to the config files for this. + ## Don'ts - **Always back up before overwriting**, with a **timestamped name and all history kept** — don't skip the backup, don't keep only a single `.bak`, don't overwrite an old backup. diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 1f24896d5f1..8f5ecb81f89 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -1755,7 +1755,7 @@ describe('goal pause classification on provider errors', () => { return { initialConfig: { providers: {}, - loopControl: { maxRetriesPerStep: 1 }, + loopControl: { maxAttemptsPerStep: 1 }, }, }; } diff --git a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts index e99b50274db..14872ee0728 100644 --- a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts +++ b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts @@ -202,14 +202,14 @@ describe('stepRetry plugin', () => { expect(result.type).toBe('cancelled'); }); - it('honors loop_control.max_retries_per_step', async () => { + it('honors loop_control.max_attempts_per_step', async () => { vi.useFakeTimers(); let calls = 0; ctx = createTestAgent(llmGenerateServices(async () => { calls += 1; throw new APIConnectionError('terminated'); }), { - initialConfig: { loopControl: { maxRetriesPerStep: 1 } }, + initialConfig: { loopControl: { maxAttemptsPerStep: 1 } }, }); const result = await runTurn(1); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 995bf58dfdb..cf201ee0bfa 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -41,6 +41,7 @@ import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection'; import '#/agent/loop/configSection'; import { LOOP_CONTROL_SECTION, + LOOP_MAX_ATTEMPTS_PER_STEP_ENV, LOOP_MAX_RETRIES_PER_STEP_ENV, LOOP_MAX_STEPS_PER_TURN_ENV, type LoopControl, @@ -772,10 +773,10 @@ describe('loopControl config section', () => { expect(registry.validate(LOOP_CONTROL_SECTION, {})).toEqual({}); expect( - registry.validate(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 100, maxRetriesPerStep: 3 }), - ).toEqual({ maxStepsPerTurn: 100, maxRetriesPerStep: 3 }); + registry.validate(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 100, maxAttemptsPerStep: 3 }), + ).toEqual({ maxStepsPerTurn: 100, maxAttemptsPerStep: 3 }); expect(() => registry.validate(LOOP_CONTROL_SECTION, { maxStepsPerTurn: -1 })).toThrow(); - expect(() => registry.validate(LOOP_CONTROL_SECTION, { maxRetriesPerStep: 1.5 })).toThrow(); + expect(() => registry.validate(LOOP_CONTROL_SECTION, { maxAttemptsPerStep: 1.5 })).toThrow(); }); it('re-applies loopControl env bindings on every get() and ignores invalid env', async () => { @@ -794,14 +795,14 @@ describe('loopControl config section', () => { expect(config.get(LOOP_CONTROL_SECTION)).toEqual({}); env[LOOP_MAX_STEPS_PER_TURN_ENV] = 'abc'; - env[LOOP_MAX_RETRIES_PER_STEP_ENV] = '-1'; + env[LOOP_MAX_ATTEMPTS_PER_STEP_ENV] = '-1'; expect(config.get(LOOP_CONTROL_SECTION)).toEqual({}); env[LOOP_MAX_STEPS_PER_TURN_ENV] = '100'; - env[LOOP_MAX_RETRIES_PER_STEP_ENV] = '3'; + env[LOOP_MAX_ATTEMPTS_PER_STEP_ENV] = '3'; expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxStepsPerTurn: 100, - maxRetriesPerStep: 3, + maxAttemptsPerStep: 3, }); env[LOOP_MAX_STEPS_PER_TURN_ENV] = '50'; @@ -813,7 +814,7 @@ describe('loopControl config section', () => { it('restores env-owned fields to the raw value on set() while the env var is set', async () => { const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: '7', - [LOOP_MAX_RETRIES_PER_STEP_ENV]: '2', + [LOOP_MAX_ATTEMPTS_PER_STEP_ENV]: '2', }; const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); @@ -835,14 +836,14 @@ describe('loopControl config section', () => { // A client echoing the env-overlaid section back (plus a genuine edit). await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7, - maxRetriesPerStep: 2, + maxAttemptsPerStep: 2, reservedContextSize: 5000, }); // Runtime resolution still lets the env win… expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxStepsPerTurn: 7, - maxRetriesPerStep: 2, + maxAttemptsPerStep: 2, reservedContextSize: 5000, }); // …but persistence keeps the raw value and drops the env-only field. @@ -853,7 +854,7 @@ describe('loopControl config section', () => { const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); expect(onDisk).toContain('max_steps_per_turn = 100'); expect(onDisk).toContain('reserved_context_size = 5000'); - expect(onDisk).not.toContain('max_retries_per_step'); + expect(onDisk).not.toContain('max_attempts_per_step'); disposables.dispose(); }); @@ -945,7 +946,7 @@ describe('loopControl config section', () => { disposables.dispose(); }); - it('restores the env-owned field from the normalized raw base when the config uses the legacy key', async () => { + it('warns and ignores the deprecated max_steps_per_run key without rewriting the file', async () => { const env: Record = { [LOOP_MAX_STEPS_PER_TURN_ENV]: '7' }; const disposables = new DisposableStore(); const ix = disposables.add(new TestInstantiationService()); @@ -964,13 +965,25 @@ describe('loopControl config section', () => { const config = ix.get(IConfigService); await config.ready; - await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7 }); - - expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(7); - // The legacy `max_steps_per_run` value is honored as the field's raw value. + // The deprecated key no longer maps onto maxStepsPerTurn: the resolved + // section carries only the env override, and the raw user value is the + // un-normalized echo of the file (preserved, not applied). + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxStepsPerTurn: 7 }); expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ - maxStepsPerTurn: 100, + maxStepsPerRun: 100, }); + // …its presence is reported as a deprecation warning… + expect(config.diagnostics()).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: + "[loop_control] 'max_steps_per_run' is deprecated and no longer used; rename it to 'max_steps_per_turn'. Run /update-config to fix it.", + }); + // …and a stripped write leaves the on-disk legacy key untouched. + await config.set(LOOP_CONTROL_SECTION, { maxStepsPerTurn: 7 }); + expect(config.get(LOOP_CONTROL_SECTION).maxStepsPerTurn).toBe(7); + const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); + expect(onDisk).toContain('max_steps_per_run = 100'); disposables.dispose(); }); @@ -1040,6 +1053,184 @@ describe('loopControl config section', () => { }); }); +describe('config deprecations', () => { + async function createConfig(env: Record, toml?: string) { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + if (toml !== undefined) { + await storage.write('', 'config.toml', new TextEncoder().encode(toml)); + } + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + return { config, disposables, storage }; + } + + it('warns and ignores a deprecated TOML key whose value no longer applies', async () => { + const { config, disposables } = await createConfig( + {}, + '[loop_control]\nmax_retries_per_step = 3\n', + ); + + // The old value is NOT mapped onto the new field… + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({}); + // …and the file is left untouched — the warning is the migration guide. + expect(config.diagnostics()).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: + "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'. Run /update-config to fix it.", + }); + + disposables.dispose(); + }); + + it('lets the replacement key win when both are present, still warning', async () => { + const { config, disposables } = await createConfig( + {}, + '[loop_control]\nmax_retries_per_step = 3\nmax_attempts_per_step = 2\n', + ); + + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 2 }); + expect(config.diagnostics()).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: + "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'. Run /update-config to fix it.", + }); + + disposables.dispose(); + }); + + it('resolves a deprecated env var as a fallback with a warning, new var first', async () => { + const env: Record = { [LOOP_MAX_RETRIES_PER_STEP_ENV]: '4' }; + const { config, disposables } = await createConfig(env); + + // The deprecated var still supplies the value… + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 4 }); + // …with a deprecation warning… + expect(config.diagnostics()).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: `Environment variable ${LOOP_MAX_RETRIES_PER_STEP_ENV} is deprecated; use ${LOOP_MAX_ATTEMPTS_PER_STEP_ENV} instead.`, + }); + // …and the replacement var wins as soon as it appears. + env[LOOP_MAX_ATTEMPTS_PER_STEP_ENV] = '2'; + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 2 }); + + disposables.dispose(); + }); + + it('reports no env deprecation when only the replacement var is set', async () => { + const env: Record = { [LOOP_MAX_ATTEMPTS_PER_STEP_ENV]: '4' }; + const { config, disposables } = await createConfig(env); + + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 4 }); + expect(config.diagnostics()).toEqual([]); + + disposables.dispose(); + }); + + it('keeps the deprecated env warning across a no-op reload', async () => { + const env: Record = { [LOOP_MAX_RETRIES_PER_STEP_ENV]: '4' }; + const { config, disposables } = await createConfig(env); + + const warning = { + domain: LOOP_CONTROL_SECTION, + severity: 'warning' as const, + message: `Environment variable ${LOOP_MAX_RETRIES_PER_STEP_ENV} is deprecated; use ${LOOP_MAX_ATTEMPTS_PER_STEP_ENV} instead.`, + }; + expect(config.diagnostics()).toContainEqual(warning); + + // The file never changed, so reload takes the unchanged early return — + // the env-derived warning must survive it. + await config.reload(); + + expect(config.diagnostics()).toContainEqual(warning); + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 4 }); + + disposables.dispose(); + }); + + it('restores the env-owned field on set() when only the deprecated env var is set', async () => { + const env: Record = { [LOOP_MAX_RETRIES_PER_STEP_ENV]: '2' }; + const { config, disposables, storage } = await createConfig( + env, + '[loop_control]\nmax_attempts_per_step = 9\n', + ); + + // A client echoing the env-overlaid section back (plus a genuine edit). + await config.set(LOOP_CONTROL_SECTION, { maxAttemptsPerStep: 2, reservedContextSize: 5000 }); + + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ + maxAttemptsPerStep: 2, + reservedContextSize: 5000, + }); + // The deprecated env still owns the field: persistence restores the raw + // value instead of leaking the echoed env value. + expect(config.inspect(LOOP_CONTROL_SECTION).userValue).toEqual({ + maxAttemptsPerStep: 9, + reservedContextSize: 5000, + }); + const onDisk = new TextDecoder().decode(await storage.read('', 'config.toml')); + expect(onDisk).toContain('max_attempts_per_step = 9'); + + disposables.dispose(); + }); + + it('emits onDidChangeDiagnostics on load and again when the warning clears', async () => { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + await storage.write( + '', + 'config.toml', + new TextEncoder().encode('[loop_control]\nmax_retries_per_step = 3\n'), + ); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', {})); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + const emissions: Array = []; + config.onDidChangeDiagnostics((diagnostics) => { + emissions.push(diagnostics); + }); + await config.ready; + + expect(emissions).toHaveLength(1); + expect(emissions[0]).toContainEqual({ + domain: LOOP_CONTROL_SECTION, + severity: 'warning', + message: + "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'. Run /update-config to fix it.", + }); + + // Renaming the key on disk clears the warning on the next reload. + await storage.write( + '', + 'config.toml', + new TextEncoder().encode('[loop_control]\nmax_attempts_per_step = 3\n'), + ); + await config.reload(); + + expect(emissions).toHaveLength(2); + expect(emissions[1]).toEqual([]); + expect(config.diagnostics()).toEqual([]); + expect(config.get(LOOP_CONTROL_SECTION)).toEqual({ maxAttemptsPerStep: 3 }); + + disposables.dispose(); + }); +}); + describe('task config section', () => { it('re-applies the keepAliveOnExit env binding on every get()', async () => { const env: Record = {}; @@ -1319,7 +1510,7 @@ describe('applyPrintModeConfigDefaults', () => { it('keeps sibling user keys of a filled section visible', async () => { const { config, disposables } = await createConfig( {}, - '[task]\nprint_background_mode = "drain"\n\n[loop_control]\nmax_retries_per_step = 5\n', + '[task]\nprint_background_mode = "drain"\n\n[loop_control]\nmax_attempts_per_step = 5\n', ); await applyPrintModeConfigDefaults(config); @@ -1327,7 +1518,7 @@ describe('applyPrintModeConfigDefaults', () => { expect(resolvePrintBackgroundMode(config)).toBe('drain'); expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(0); expect(config.get(LOOP_CONTROL_SECTION)).toMatchObject({ - maxRetriesPerStep: 5, + maxAttemptsPerStep: 5, maxStepsPerTurn: 0, }); diff --git a/packages/agent-core-v2/test/kosong/stubs.ts b/packages/agent-core-v2/test/kosong/stubs.ts index 50b6262c407..6ad9ade9994 100644 --- a/packages/agent-core-v2/test/kosong/stubs.ts +++ b/packages/agent-core-v2/test/kosong/stubs.ts @@ -22,6 +22,9 @@ export class StubConfigService implements IConfigService { private readonly _onDidChange = new Emitter(); readonly onDidChangeConfiguration: Event = this._onDidChange.event; readonly onDidSectionChange: Event = this._onDidChange.event; + private readonly _onDidChangeDiagnostics = new Emitter(); + readonly onDidChangeDiagnostics: Event = + this._onDidChangeDiagnostics.event; private readonly _values = new Map(); constructor(initial?: Record) { diff --git a/packages/agent-core/src/skill/builtin/update-config.md b/packages/agent-core/src/skill/builtin/update-config.md index 85901142442..15583877486 100644 --- a/packages/agent-core/src/skill/builtin/update-config.md +++ b/packages/agent-core/src/skill/builtin/update-config.md @@ -1,6 +1,6 @@ --- name: update-config -description: Inspect or edit kimi-code's own config — `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update). Use when the user asks what a setting does or wants to change one. +description: Inspect or edit kimi-code's own config — `config.toml` (model, provider, permission, hooks) and `tui.toml` (theme, editor, notifications, auto-update). Use when the user asks what a setting does, wants to change one, or needs to fix a deprecated config key / environment variable warning. --- # Configure kimi-code (update-config) @@ -97,6 +97,13 @@ Once local validation passes, tell the user how to make the change take effect Note: `/reload` is available **only when idle** — if a reply is streaming, press Esc / Ctrl-C to stop first. `kimi doctor` already validated the schema before the overwrite, so reload should apply cleanly; if it still errors, follow the message to fix it or recover from the most recent timestamped backup. If you don't want to reload now, the **next new session** picks it up automatically. +## Capability 5: fix a deprecated key or env-var warning + +kimi reports configuration deprecations as warnings — in the TUI startup notices and pushed to clients as the `event.config.warning` event. There are two shapes, handled differently: + +- **Deprecated TOML key** — e.g. `[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'.` The old value no longer applies, so fix it promptly: follow the Capability 2 flow (copy → Edit → validate → back up → overwrite) and **rename the key in `config.toml`, keeping its value unchanged**. The warning names the exact section and replacement key — use those; never guess other renames. After `/reload`, the warning disappears. +- **Deprecated environment variable** — e.g. `Environment variable KIMI_LOOP_MAX_RETRIES_PER_STEP is deprecated; use KIMI_LOOP_MAX_ATTEMPTS_PER_STEP instead.` The old variable still works, but this is **not** fixable by editing `config.toml`/`tui.toml` — tell the user to rename the variable where they set it (shell profile, CI environment, launcher script). Do not add anything to the config files for this. + ## Don'ts - **Always back up before overwriting**, with a **timestamped name and all history kept** — don't skip the backup, don't keep only a single `.bak`, don't overwrite an old backup. diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 22ce7ca705a..9c5cbba17dd 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -615,6 +615,16 @@ export const configChangedEventSchema = z.object({ config: configResponseSchema, }); +export const configWarningEventSchema = z.object({ + type: z.literal('event.config.warning'), + warnings: z.array( + z.object({ + domain: z.string().optional(), + message: z.string(), + }), + ), +}); + export const goalUpdatedEventSchema = z.object({ type: z.literal('goal.updated'), snapshot: goalSnapshotSchema.nullable(), diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index fcf986dbdc3..43da3353538 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -10,12 +10,14 @@ import { bootstrap, IConfigService, + IEventService, IProviderDiscoveryService, IWorkspaceService, logSeed, resolveConfigPath, resolveKimiHome, resolveLoggingConfig, + type ConfigDiagnostic, type Scope, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; @@ -49,6 +51,7 @@ import { } from './transport/ws/connectionRegistry'; import { extractWsBearerToken } from './transport/ws/bearerProtocol'; import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcaster'; +import type { ConfigWarningItem } from './transport/ws/v1/events'; import { FsWatchBridge } from './transport/ws/v1/fsWatchBridge'; import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; import { getServerVersion } from './version'; @@ -350,6 +353,7 @@ export async function startServer(opts: ServerStartOptions): Promise => { await app.close(); + configWarningSubscription.dispose(); authFailureLimiter?.dispose(); modelCatalogRefreshScheduler.dispose(); // Telemetry is best-effort and must never prevent core or instance cleanup. @@ -386,6 +390,37 @@ export async function startServer(opts: ServerStartOptions): Promise { + const warnings: ConfigWarningItem[] = diagnostics + .filter((diagnostic) => diagnostic.severity === 'warning') + .map((diagnostic) => + diagnostic.domain === undefined + ? { message: diagnostic.message } + : { domain: diagnostic.domain, message: diagnostic.message }, + ); + core.accessor.get(IEventService).publish({ + type: 'event.config.warning', + payload: { warnings }, + }); + }; + const configWarningSubscription = configService.onDidChangeDiagnostics(publishConfigWarnings); + void configService.ready + .then(() => { + if (configService.diagnostics().some((diagnostic) => diagnostic.severity === 'warning')) { + publishConfigWarnings(configService.diagnostics()); + } + }) + .catch(() => { + /* config readiness is best-effort; warnings are advisory */ + }); + async function registerOpenApi(): Promise { const { default: swagger } = await import('@fastify/swagger'); await app.register(swagger, { diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index 0225299a4a7..629796ba63d 100644 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -96,6 +96,23 @@ export interface ConfigChangedEvent { readonly config: ConfigResponse; } +export interface ConfigWarningItem { + readonly domain?: string; + readonly message: string; +} + +/** + * Global config warnings (deprecated keys / env vars in use, invalid + * sections). Pushed live to every connection whenever the config service's + * warning set changes; an empty `warnings` array means the last warning + * cleared. Late joiners are not replayed — pull current warnings via the + * config diagnostics RPC surface instead. + */ +export interface ConfigWarningEvent { + readonly type: 'event.config.warning'; + readonly warnings: readonly ConfigWarningItem[]; +} + export interface PromptSubmittedEvent { readonly type: 'prompt.submitted'; readonly promptId: string; @@ -178,6 +195,7 @@ export type AgentEvent = | SessionWorkChangedEvent | SessionStatusChangedEvent | ConfigChangedEvent + | ConfigWarningEvent | PromptSubmittedEvent | BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent; diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index e49fca40fd1..5f5b18d9778 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -73,7 +73,12 @@ import { MAIN_AGENT_ID, getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; -import type { SessionCreatedEvent, SessionMetaUpdatedEvent, Event } from './events'; +import type { + ConfigWarningItem, + SessionCreatedEvent, + SessionMetaUpdatedEvent, + Event, +} from './events'; import { isVolatileEventType } from './events'; import type { SessionCursor } from '../../../protocol/ws-control'; import type { InFlightTurn, SnapshotSubagent } from '../../../protocol/rest-snapshot'; @@ -874,6 +879,23 @@ export class SessionEventBroadcaster { } as Event).catch((error: unknown) => this.logDispatchError(sessionId, 'session.meta.updated', error), ); + return; + } + if (event.type === 'event.config.warning') { + const payload = configWarningPayload(event.payload); + if (payload === undefined) return; + // Global fan-out: every established connection learns the current config + // warning set (deprecated keys/env vars in use, invalid sections) without + // subscribing to anything. Delivery is live-only — late joiners pull the + // diagnostics RPC surface instead. + void this.dispatchGlobal({ + type: 'event.config.warning', + warnings: payload.warnings, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.config.warning', error), + ); } } @@ -1584,3 +1606,25 @@ function sessionCreatedPayload( if (sessionId === undefined || session === undefined) return undefined; return { sessionId, session }; } + +/** + * Validate the `event.config.warning` payload published on the core + * `IEventService` (`{ warnings: [{ domain?, message }] }`). Any malformed + * entry rejects the whole batch — the publisher always sends the full current + * warning set, so a partial frame would be a lie by omission. + */ +function configWarningPayload(payload: unknown): { warnings: ConfigWarningItem[] } | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const warnings = (payload as { warnings?: unknown }).warnings; + if (!Array.isArray(warnings)) return undefined; + const items: ConfigWarningItem[] = []; + for (const warning of warnings) { + if (typeof warning !== 'object' || warning === null) return undefined; + const message = (warning as { message?: unknown }).message; + if (typeof message !== 'string' || message.length === 0) return undefined; + const domain = (warning as { domain?: unknown }).domain; + if (domain !== undefined && typeof domain !== 'string') return undefined; + items.push(typeof domain === 'string' ? { domain, message } : { message }); + } + return { warnings: items }; +} diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 234ed7ce943..fb56f9277d7 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -1091,6 +1091,49 @@ describe('SessionEventBroadcaster', () => { await bc.getCursor('s1'); // drain any would-be duplicate expect(both.envelopes).toHaveLength(1); }); + + it('delivers event.config.warning to a global-only target that never subscribed', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + const warnings = [ + { + domain: 'loopControl', + message: + "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'.", + }, + { message: 'Environment variable OLD_VAR is deprecated; use NEW_VAR instead.' }, + ]; + eventBus.emit({ type: 'event.config.warning', payload: { warnings } }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.config.warning', + session_id: '__global__', + payload: { warnings }, + }); + expect(globalView.deliveries).toEqual(['immediate']); + }); + + it('drops malformed event.config.warning payloads', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + eventBus.emit({ type: 'event.config.warning', payload: { warnings: [{ message: 42 }] } }); + eventBus.emit({ type: 'event.config.warning', payload: { warnings: 'nope' } }); + eventBus.emit({ type: 'event.config.warning', payload: null }); + + // A valid frame right after proves the malformed ones were dropped, not + // merely slow. + const warnings = [{ message: 'something deprecated' }]; + eventBus.emit({ type: 'event.config.warning', payload: { warnings } }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.config.warning', + payload: { warnings }, + }); + }); }); it('emits a durable event.session.work_changed(busy) trailing turn.started', async () => { diff --git a/packages/klient/test/e2e/invalid-input-matrix.test.ts b/packages/klient/test/e2e/invalid-input-matrix.test.ts index 08af5990289..5abd80a05d2 100644 --- a/packages/klient/test/e2e/invalid-input-matrix.test.ts +++ b/packages/klient/test/e2e/invalid-input-matrix.test.ts @@ -841,7 +841,7 @@ describe('video blocks', () => { // "Unsupported media type for base64 video" does NOT match the // image-format non-retryable patterns, so stepRetry claims it. Cap the // retries at 2 attempts (1 re-run, ~500ms backoff) for the suite's sake. - await klient.global.config.set({ domain: 'loopControl', patch: { maxRetriesPerStep: 2 } }); + await klient.global.config.set({ domain: 'loopControl', patch: { maxAttemptsPerStep: 2 } }); try { const ctx = await newCase(M_ANTHROPIC, 'anthropic-video-mime'); resetMock(queueScript(OK_ANTHROPIC)); @@ -871,7 +871,7 @@ describe('video blocks', () => { expect(ctx.eventNames()).toEqual(['turn.started', 'turn.ended', 'error', 'prompt.completed']); expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('failed'); } finally { - await klient.global.config.set({ domain: 'loopControl', patch: { maxRetriesPerStep: 10 } }); + await klient.global.config.set({ domain: 'loopControl', patch: { maxAttemptsPerStep: 10 } }); } }, 30_000); }); From 247aa05ebb00ab4db29ab7470d953157abf0b2ff Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 3 Aug 2026 20:59:46 +0800 Subject: [PATCH 10/33] fix(agent-core): replay v2 profile.bind records so resumed sessions keep their tools (#2567) * fix(agent-core): replay v2 profile.bind records so resumed sessions keep their tools Sessions created by the v2 engine (CLI 0.31+, wire protocol 1.5) persist the profile binding, including the tool allowlist, as a profile.bind record. The v1 replay path had no branch for it and silently dropped the record, so a session resumed by a v1 host (e.g. the VS Code extension via kimi-code-sdk) never called setActiveTools and sent requests with no tools at all (observed server-side as tools_count=0; the model emits reasoning only and stops with empty content). v1 replay now maps profile.bind onto config.update + setActiveTools when activeToolNames is an array, skips the record otherwise so the resume-time default-profile fallback still applies, and treats tools.reset_active_tools as a no-op. * fix(vis): handle v2 profile records in context projection * fix(agent-core): avoid synthetic replay for v2 profile binds * fix(vis): render v2 profile wire records --- .changeset/v1-replay-profile-bind.md | 5 ++ apps/vis/server/src/lib/context-projector.ts | 2 + .../vis/web/src/components/wire/renderers.tsx | 31 ++++++++ packages/agent-core/src/agent/config/index.ts | 24 +++++- .../agent-core/src/agent/records/index.ts | 28 +++++++ .../agent-core/src/agent/records/types.ts | 27 +++++++ .../test/agent/records/index.test.ts | 77 +++++++++++++++++++ 7 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 .changeset/v1-replay-profile-bind.md diff --git a/.changeset/v1-replay-profile-bind.md b/.changeset/v1-replay-profile-bind.md new file mode 100644 index 00000000000..2818e4f0b18 --- /dev/null +++ b/.changeset/v1-replay-profile-bind.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fix v1 replay ignoring v2 `profile.bind` records, which made sessions resumed from CLI-created wires lose their tool allowlist and send requests without `tools`. diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index 76cdec45874..b70e9881535 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -514,6 +514,8 @@ export function projectContext( case 'tools.unregister_user_tool': case 'tools.set_active_tools': case 'tools.update_store': + case 'profile.bind': + case 'tools.reset_active_tools': case 'llm.tools_snapshot': case 'llm.request': case 'mcp.tools_discovered': diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 126ac3bdede..d59b239cf0f 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -83,6 +83,29 @@ export const WIRE_RENDERERS: RendererMap = { }, }, + 'profile.bind': { + tone: 'config', + label: 'profile', + headline: (r) => { + const parts: string[] = []; + if (r.profileName !== undefined) parts.push(`profile=${r.profileName}`); + if (r.modelAlias !== undefined) parts.push(`model=${r.modelAlias}`); + if (r.thinkingEffort !== undefined) parts.push(`thinking=${r.thinkingEffort}`); + if (r.activeToolNames !== undefined) { + parts.push(`${r.activeToolNames.length} tools`); + } else { + parts.push('all tools'); + } + return { + main: ( + + {parts.length === 0 ? (no fields) : parts.join(' · ')} + + ), + }; + }, + }, + 'turn.prompt': { tone: 'turn', label: 'prompt', @@ -324,6 +347,14 @@ export const WIRE_RENDERERS: RendererMap = { }, }, + 'tools.reset_active_tools': { + tone: 'tools', + label: 'reset', + headline: () => ({ + main: all tools active, + }), + }, + 'tools.update_store': { tone: 'meta', label: 'store', diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 725186b098f..56960604295 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -45,6 +45,20 @@ export class ConfigState { } update(changed: AgentConfigUpdateData): void { + this.applyUpdate(changed, true); + } + + /** + * Restore config state without synthesizing a v1 replay record. This is + * used when a v2-only wire record is projected onto v1 state: the state + * should be available to the resumed agent, but the v2 record must not + * appear as a `config_updated` event in the replay surface. + */ + restore(changed: AgentConfigUpdateData): void { + this.applyUpdate(changed, false); + } + + private applyUpdate(changed: AgentConfigUpdateData, emitReplayRecord: boolean): void { if (Object.keys(changed).length === 0) return; const targetAlias = changed.modelAlias ?? this._modelAlias; @@ -86,10 +100,12 @@ export class ConfigState { type: 'config.update', ...effectiveChanged, }); - this.agent.replayBuilder.push({ - type: 'config_updated', - config: effectiveChanged, - }); + if (emitReplayRecord) { + this.agent.replayBuilder.push({ + type: 'config_updated', + config: effectiveChanged, + }); + } if (changed.cwd) { this._cwd = changed.cwd; this.agent.setKaos(this.agent.kaos.withCwd(changed.cwd)); diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 29511a738a5..309c79edaf8 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -48,6 +48,34 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { case 'config.update': agent.config.update(input); return; + case 'profile.bind': { + // v2-engine wires persist the profile binding (including the tool + // allowlist) via profile.bind instead of the v1 pair of config.update + + // tools.set_active_tools. Map it onto the v1 equivalents so a v2 + // session resumed here keeps its model, prompt, and tools. Records + // without an activeToolNames array (v2's "every tool active") are + // skipped wholesale: leaving the config untouched preserves the + // session-level fallback that applies the default profile when the + // replayed system prompt is empty, matching how names-less + // tools.set_active_tools records are treated. + if (!Array.isArray(input.activeToolNames)) return; + const thinkingEffort = input.thinkingEffort ?? input.thinkingLevel; + agent.config.restore({ + ...(input.modelAlias !== undefined ? { modelAlias: input.modelAlias } : {}), + ...(input.profileName !== undefined ? { profileName: input.profileName } : {}), + ...(thinkingEffort !== undefined ? { thinkingEffort } : {}), + ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}), + ...(input.subagents !== undefined ? { subagentNames: input.subagents } : {}), + }); + agent.tools.setActiveTools(input.activeToolNames, input.disallowedTools); + return; + } + case 'tools.reset_active_tools': + // v2-only transition back to the unrestricted default (every tool + // active). v1 keeps no "all tools" state to restore — the + // session-level profile fallback covers fresh resumes — so the record + // replays as a no-op. + return; case 'permission.set_mode': agent.permission.setMode(input.mode); return; diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index e9c1e1b240f..ac5ddb9b211 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -53,6 +53,33 @@ export interface AgentRecordEvents { 'config.update': AgentConfigUpdateData; + /** + * v2-engine profile binding (wire protocol 1.5). v1 never writes this + * record; the type exists so replay can map a v2 session's profile binding + * onto the v1 equivalents (`config.update` + `tools.set_active_tools`). + * Field shapes follow the v2 payload: live v2 records carry + * `thinkingEffort`, legacy ones may carry `thinkingLevel` instead. + */ + 'profile.bind': { + modelAlias?: string; + profileName?: string; + thinkingEffort?: string; + thinkingLevel?: string; + systemPrompt?: string; + /** v2 tool allowlist; absent means "every tool active". */ + activeToolNames?: readonly string[]; + /** v2 profile denylist, applied on top of `activeToolNames`. */ + disallowedTools?: readonly string[]; + subagents?: readonly string[]; + }; + + /** + * v2-engine transition back to the unrestricted default (every tool + * active). v1 has no corresponding state to rebuild; replay treats it as a + * no-op so the session-level profile fallback keeps its behavior. + */ + 'tools.reset_active_tools': {}; + 'permission.set_mode': { mode: PermissionMode; }; diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts index 6bfc1a77136..ade82d96bed 100644 --- a/packages/agent-core/test/agent/records/index.test.ts +++ b/packages/agent-core/test/agent/records/index.test.ts @@ -285,6 +285,83 @@ describe('AgentRecords persistence metadata', () => { expect(names).not.toContain('Write'); }); + it('replays a v2 profile.bind record as config.update + tools.set_active_tools', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + // v2-engine wires are stamped with protocol 1.5. + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + { + type: 'profile.bind', + modelAlias: 'mock-model', + profileName: 'coding', + thinkingEffort: 'off', + systemPrompt: 'You are a v2 coding agent.', + activeToolNames: ['Read', 'Write', 'Bash'], + disallowedTools: ['Write'], + subagents: ['explore'], + } as AgentRecord, + ]); + const { agent } = testAgent({ persistence }); + + await agent.records.replay(); + + expect(agent.config.modelAlias).toBe('mock-model'); + expect(agent.config.profileName).toBe('coding'); + expect(agent.config.systemPrompt).toBe('You are a v2 coding agent.'); + expect(agent.config.subagentNames).toEqual(['explore']); + expect(agent.replayBuilder.buildResult().map((record) => record.type)).not.toContain( + 'config_updated', + ); + const names = agent.tools.loopTools.map((tool) => tool.name); + expect(names).toContain('Read'); + expect(names).toContain('Bash'); + expect(names).not.toContain('Write'); + }); + + it('skips a profile.bind record without activeToolNames so the profile fallback still fires', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + // v2's "every tool active" binding: no allowlist to restore. The record + // must be ignored wholesale so the session-level default-profile + // fallback (gated on an empty replayed system prompt) keeps firing. + { + type: 'profile.bind', + modelAlias: 'mock-model', + systemPrompt: 'You are a v2 agent.', + } as AgentRecord, + { type: 'goal.create', goalId: 'g1', objective: 'do work' } as AgentRecord, + ]); + const { agent } = testAgent({ persistence }); + + await agent.records.replay(); + + expect(agent.config.systemPrompt).toBe(''); + // Replay continued past the skipped record. + expect(agent.goal.getGoal().goal?.goalId).toBe('g1'); + }); + + it('replays a v2 tools.reset_active_tools record as a no-op', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + { + type: 'tools.set_active_tools', + names: ['Read'], + } as AgentRecord, + { type: 'tools.reset_active_tools' } as AgentRecord, + { type: 'goal.create', goalId: 'g1', objective: 'do work' } as AgentRecord, + ]); + const { agent } = testAgent({ persistence }); + agent.config.update({ modelAlias: 'mock-model' }); + + await agent.records.replay(); + + // v1 has no "all tools" state to restore; the earlier restriction stays + // (fails closed) and replay continues past the record. + const names = agent.tools.loopTools.map((tool) => tool.name); + expect(names).toContain('Read'); + expect(names).not.toContain('Write'); + expect(agent.goal.getGoal().goal?.goalId).toBe('g1'); + }); + it('restores goal.* records during replay', async () => { const persistence = new InMemoryAgentRecordPersistence([ { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, From 58b404324d63705115d2bb321286b937eb757805 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 3 Aug 2026 22:02:01 +0800 Subject: [PATCH 11/33] feat(agent-core-v2): add AGENTS.md discovery reminder (#2545) * feat(agent-core-v2): add AGENTS.md discovery reminder behind experimental flag When a tool call touches a directory whose AGENTS.md was not part of the injected instructions (the init-time load only covers the project-root to cwd chain), append a system reminder to the tool result suggesting the model read it, at most once per file per agent. The new agentsMdReminder domain (Agent scope, gated by the agents-md-reminder experimental flag, default off) hooks toolExecutor.onDidExecuteTool: Read/Edit/Write contribute their path, Glob/Grep their search root, and Bash its structured cwd plus literal directory operands extracted from the command's syntax tree via the in-repo bash parser (listing commands only, bare names, conservative skips). Probing walks projectRoot to the touched dir with the same candidate rules as the init-time load (shared helpers in profile/context), skipping fully-known directories and blank files. The known-set is seeded by profileService after each successful bind/apply/refresh and by sessionInit after /init, claimed synchronously per discovered file, and published as a whole value only after the reminder is attached, so parallel calls never duplicate a reminder and failures leave files eligible for the next touch. * fix(agent-core-v2): keep the AGENTS.md reminder on visible results and seed restored agents A same-step duplicate vetoed by toolDedupe carries a placeholder result that the dedupe hook swaps for the original's deferred result; attaching the reminder there discarded it while the file was already counted as reminded, and the telemetry still claimed it was shown. Skip the placeholder (the call id sits in toolDedupe.syntheticCallIds until the dedupe hook consumes it) so the reminder, telemetry, and known-set only advance on results that reach the model; the original call then carries the reminder for both by the time the deferred resolves. Session resume and forks commit an already-rendered system prompt (AGENTS.md content included) without going through bind/apply/refresh, so no seed point fired and the known-set lagged behind the injected set, producing false "not part of the injected instructions" reminders. The first qualifying call of a never-seeded agent now re-runs the init-time discovery with the same inputs (agent cwd, os home, brand home) and seeds from it, once per agent; a discovery failure leaves the agent unseeded so the next touch retries. * refactor(agent-core-v2): fold agentsMdReminder inline rationale into file headers The package comment convention keeps rationale in the top-of-file block only; move the inline blocks' unique increments there (hook-order fallback mechanics, synthetic-key existence condition, frozen-vs-live Bash base, operand-less vs failed-resolution listings) and derive AGENTS_MD_BASENAMES from AGENTS_MD_PLAIN_NAMES so the candidate names stay single-sourced. * fix(agent-core-v2): use resolved accesses for AGENTS reminders * feat(agent-core-v2): drop the agentsMdReminder experimental gate * fix(agent-core-v2): harden AGENTS reminder tool outcomes * fix(agent-core-v2): preserve actual tool execution outcomes * Persist AGENTS.md paths across profile restoration * test(agent-core-v2): update useProfile snapshot for agentsMdPaths --- .../agent-core-v2/docs/state-manifest.d.ts | 10 +- .../agent-core-v2/docs/wire-manifest.d.ts | 2 + .../agentsMdReminder/agentsMdReminder.ts | 21 + .../agentsMdReminderService.ts | 369 ++++++ .../src/agent/agentsMdReminder/bashTargets.ts | 249 ++++ .../src/agent/profile/context.ts | 106 +- .../src/agent/profile/profile.ts | 4 + .../src/agent/profile/profileOps.ts | 19 +- .../src/agent/profile/profileService.ts | 41 +- .../agent/toolExecutor/toolExecutorService.ts | 100 +- .../src/agent/toolExecutor/toolHooks.ts | 26 +- .../agent-core-v2/src/app/telemetry/events.ts | 18 + packages/agent-core-v2/src/index.ts | 2 + .../session/sessionInit/sessionInitService.ts | 13 +- .../instructionsProvider.ts | 9 +- .../workspaceInstructions.ts | 1 + .../workspaceInstructionsService.ts | 15 +- .../agentsMdReminder/agentsMdReminder.test.ts | 1179 +++++++++++++++++ .../test/agent/goal/goal.test.ts | 1 + .../test/agent/profile/binding.test.ts | 74 +- .../test/agent/profile/context.test.ts | 52 +- .../test/agent/profile/profileOps.test.ts | 6 + .../test/agent/toolDedupe/toolDedupe.test.ts | 1 + .../agent/toolExecutor/toolExecutor.test.ts | 16 +- .../test/app/config/config.test.ts | 2 +- packages/agent-core-v2/test/harness/agent.ts | 5 +- .../agentLifecycle/agentLifecycle.test.ts | 6 + .../session/sessionInit/sessionInit.test.ts | 6 + 28 files changed, 2281 insertions(+), 72 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts create mode 100644 packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts create mode 100644 packages/agent-core-v2/src/agent/agentsMdReminder/bashTargets.ts create mode 100644 packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index d6ed6bd8bc4..f967c7f265e 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -23,7 +23,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 67 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 70 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -57,6 +57,9 @@ // activityView.lastTurn src/agent/activityView/activityViewService.ts // activityView.lifecycle src/agent/activityView/activityViewService.ts // activityView.turn src/agent/activityView/activityViewService.ts +// agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts +// agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts +// agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts // contextInjector.isNewTurn src/agent/contextInjector/contextInjectorService.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts // contextSize.lastEmittedTokens src/agent/contextSize/contextSizeService.ts @@ -135,6 +138,7 @@ export interface WorkspaceStateSnapshot { 'workspaceInstructions.current': /* WorkspaceInstructionsSnapshot — packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts */ { readonly agentsMd: string | undefined; readonly agentsMdWarning: string | undefined; + readonly agentsMdPaths: readonly string[] | undefined; }; // src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts 'workspaceSkillCatalog.contributions': Map; + 'agentsMdReminder.seeded': boolean; // src/agent/contextInjector/contextInjectorService.ts 'contextInjector.isNewTurn': boolean; // src/agent/contextProjector/contextProjectorService.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 049d4bb86b0..0e88cf9c76f 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -82,6 +82,7 @@ interface ConfigUpdatePayload { /** ThinkingEffort */ thinkingLevel?: 'off' | 'on' | (string & {}); systemPrompt?: string; + agentsMdPaths?: string[]; disallowedTools?: string[]; } @@ -460,6 +461,7 @@ interface ProfileBindPayload { /** ThinkingEffort */ thinkingEffort: 'off' | 'on' | (string & {}); systemPrompt: string; + agentsMdPaths?: string[]; activeToolNames?: string[]; disallowedTools: string[]; subagents?: string[]; diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts new file mode 100644 index 00000000000..a7efdff13c8 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts @@ -0,0 +1,21 @@ +/** + * `agentsMdReminder` domain — AGENTS.md discovery-reminder contract. + * + * Defines the `IAgentAgentsMdReminderService`, the seed side of the domain: + * `profile` reports the AGENTS.md paths it injected into the system prompt + * (on every profile apply, with the agent's effective cwd), and `sessionInit` + * re-seeds after `/init` regenerates the file, so the reminder hook can tell + * "already injected" apart from newly discovered instruction files. Bound at + * Agent scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentAgentsMdReminderService { + readonly _serviceBrand: undefined; + + seedInjected(paths: readonly string[], cwd: string): void; +} + +export const IAgentAgentsMdReminderService: ServiceIdentifier = + createDecorator('agentAgentsMdReminderService'); diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts new file mode 100644 index 00000000000..76d2a59d055 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -0,0 +1,369 @@ +/** + * `agentsMdReminder` domain — `IAgentAgentsMdReminderService` + * implementation. + * + * Self-wiring plugin: registers an `onDidExecuteTool` hook on `toolExecutor` + * that probes the directories a tool call touches for AGENTS.md files the + * system prompt did not inject, and prepends a once-per-agent + * `` to the result suggesting the model read them (head + * insertion on purpose: oversized results are truncated to a short head + * preview later in the execution pipeline, and a tail reminder would be + * silently dropped after the file was already counted as reminded). + * `Read`/`Edit`/`Write` consume the canonical file access declared by their + * resolved execution (a successful touch landing on an AGENTS.md itself marks + * just that file known), `Glob`/`Grep` consume their canonical search root, + * and `Bash` contributes its explicit `cwd` plus the literal directory + * operands extracted from the command's syntax tree (see `./bashTargets`), + * resolved against the frozen + * `sessionContext.cwd` exactly like the Bash tool itself (`args.cwd ?? + * sessionContext.cwd` — a base that deliberately differs from the live agent + * cwd after a chdir). Only calls whose `ToolDidExecuteContext.outcome` is + * `executed` are probed: preflight rejects, resolution failures, aborts, + * permission vetoes, and synthetic/duplicate results have not touched the + * requested resource and are left unchanged. The hook is ordered before + * `toolDedupe` so an executed original carries the reminder into the + * deferred result returned for a duplicate; no dedupe implementation state is + * needed here. The ordered registration throws when its target is absent, so + * scopes without `toolDedupe` fall back to plain append-order registration, + * which still lands ahead of a `toolDedupe` hook constructed later. + * + * Known-set discipline: candidates are claimed synchronously per discovered + * file into an in-memory `claimed` set (parallel calls can never duplicate a + * reminder and a failed attempt releases the claim), while `agentState` + * (`agentsMdReminder.known`) is only ever whole-value replaced after the + * reminder text is attached and the telemetry emitted — never mutated in + * place, and never ahead of the reminder it records. Probing anchors at the + * nearest existing ancestor (so `Write` into a not-yet-created directory + * still resolves), walks `findProjectRoot → touched dir`, skips chain + * directories whose candidates are all known, and applies the same + * per-directory candidate rules as the init-time load (shared through + * `profile/context`'s `findAgentsMdInDir`; blank files are included in + * neither). Directories with unknown candidates are re-statted on every + * qualifying call — deliberate, so an AGENTS.md created mid-session is + * picked up on the next touch; there is no negative cache. Probing is + * lexical like the tools' own path policy: a symlinked directory's AGENTS.md + * is discovered through the link at its lexical address, never by realpath. + * The hook never throws — a probe failure yields the untouched result. + * + * Seeding: `profile` reports the injected paths after every successful + * bind/apply/refresh and `sessionInit` re-seeds after `/init`. A prompt can + * also commit without any of those entry points — session resume and forks + * restore the already-rendered system prompt (AGENTS.md content included) + * from the wire journal or a binding snapshot. The wire restore hook seeds + * the exact persisted paths (legacy prompts recover their source annotations), + * so the first qualifying call of a never-seeded agent does not confuse the + * current filesystem with the restored prompt. The seeded cwd lives in + * `agentState` as well; restored provenance comes from `wire`/`profile`; fs + * probes go through the os `IHostFileSystem`, the home directory through + * `IHostEnvironment`, the brand home through `bootstrap`, syntax + * trees through `bashParser`, and the shown-event + * through `telemetry`. Bound at Agent scope. + */ + +import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; +import { IBashParserService } from '#/app/bashParser/bashParser'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { AgentsMdReminderShownEvent } from '#/app/telemetry/events'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ContentPart } from '#/kosong/contract/message'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import type { ExecutableToolOutput, ExecutableToolResult } from '#/tool/toolContract'; +import { normalizeUserPath } from '#/tool/path-access'; +import { + AGENTS_MD_PLAIN_NAMES, + agentsMdCandidatePaths, + dirsRootToLeaf, + findAgentsMdInDir, + findProjectRoot, + extractAgentsMdPathsFromSystemPrompt, + loadAgentsMdDetailed, +} from '#/agent/profile/context'; +import { ProfileModel } from '#/agent/profile/profileOps'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; +import { IWireService } from '#/wire/wire'; + +import { IAgentAgentsMdReminderService } from './agentsMdReminder'; +import { extractBashTargetDirs } from './bashTargets'; + +const AGENTS_MD_BASENAMES: ReadonlySet = new Set(AGENTS_MD_PLAIN_NAMES); + +const BASH_PARSE_OPTIONS = { timeoutMs: 20, maxNodes: 10_000 } as const; + +export const agentsMdReminderKnownKey = defineState>( + 'agentsMdReminder.known', + () => new Set(), +); +export const agentsMdReminderCwdKey = defineState( + 'agentsMdReminder.cwd', + () => undefined as string | undefined, +); +export const agentsMdReminderSeededKey = defineState( + 'agentsMdReminder.seeded', + () => false, +); + +export class AgentAgentsMdReminderService + extends Disposable + implements IAgentAgentsMdReminderService +{ + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentStateService private readonly states: IAgentStateService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IHostEnvironment private readonly env: IHostEnvironment, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IBashParserService private readonly bashParser: IBashParserService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IWireService private readonly wire: IWireService, + ) { + super(); + this.states.register(agentsMdReminderKnownKey); + this.states.register(agentsMdReminderCwdKey); + this.states.register(agentsMdReminderSeededKey); + this._register( + this.wire.hooks.onDidRestore.register('agentsMdReminder', async (_ctx, next) => { + const profile = this.wire.getModel(ProfileModel); + const paths = + profile.agentsMdPaths ?? extractAgentsMdPathsFromSystemPrompt(profile.systemPrompt); + this.seedInjected(paths, this.sessionContext.cwd); + await next(); + }), + ); + const handler = async (ctx: ToolDidExecuteContext, next: () => Promise): Promise => { + ctx.result = await this.augmentWithReminder(ctx); + await next(); + }; + try { + this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler, { before: 'toolDedupe' })); + } catch { + this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); + } + } + + seedInjected(paths: readonly string[], cwd: string): void { + const known = this.states.get(agentsMdReminderKnownKey); + for (const path of paths) known.add(normalize(path)); + this.states.set(agentsMdReminderKnownKey, new Set(known)); + this.states.set(agentsMdReminderCwdKey, cwd); + this.states.set(agentsMdReminderSeededKey, true); + } + + private readonly claimed = new Set(); + + private get known(): Set { + return this.states.get(agentsMdReminderKnownKey); + } + + private get agentCwd(): string { + return this.states.get(agentsMdReminderCwdKey) ?? this.sessionContext.cwd; + } + + private async ensureSeeded(): Promise { + if (this.states.get(agentsMdReminderSeededKey)) return; + const { paths } = await loadAgentsMdDetailed( + { fs: this.fs, homeDir: this.env.homeDir }, + this.agentCwd, + this.bootstrap.homeDir, + ); + this.seedInjected(paths, this.agentCwd); + } + + private async augmentWithReminder(ctx: ToolDidExecuteContext): Promise { + if (ctx.outcome !== 'executed') return ctx.result; + const discovered: string[] = []; + try { + await this.ensureSeeded(); + const { dirs, selfKnown } = this.targetDirs(ctx); + const selfKnownSet = new Set(selfKnown); + for (const dir of dirs) { + for (const path of await this.probeDir(dir)) { + if (this.known.has(path) || this.claimed.has(path) || selfKnownSet.has(path)) continue; + this.claimed.add(path); + discovered.push(path); + } + } + if (discovered.length === 0) { + this.publishKnown(selfKnown); + return ctx.result; + } + const result = prependReminder(ctx.result, reminderText(discovered)); + 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.publishKnown([...selfKnown, ...discovered]); + return result; + } catch { + return ctx.result; + } finally { + for (const path of discovered) this.claimed.delete(path); + } + } + + private publishKnown(paths: readonly string[]): void { + if (paths.length === 0) return; + const merged = new Set(this.known); + for (const path of paths) merged.add(path); + this.states.set(agentsMdReminderKnownKey, merged); + } + + private targetDirs(ctx: ToolDidExecuteContext): { dirs: string[]; selfKnown: string[] } { + const selfKnown: string[] = []; + switch (ctx.toolCall.name) { + case 'Read': + case 'Edit': + case 'Write': + case 'Glob': + case 'Grep': + return this.targetDirsFromAccesses(ctx); + case 'Bash': { + const args = ctx.args; + const command = stringArg(args, 'command'); + if (command === undefined) return { dirs: [], selfKnown }; + const cwdArg = stringArg(args, 'cwd'); + const base = hostPath(this.sessionContext.cwd, this.env.pathClass); + const normalizedCwdArg = + cwdArg === undefined ? undefined : normalizeUserPath(cwdArg, this.env.pathClass); + const effectiveCwd = + normalizedCwdArg === undefined + ? base + : normalize( + isAbsolute(normalizedCwdArg) + ? normalizedCwdArg + : join(base, normalizedCwdArg), + ); + const parsed = this.bashParser.parse(command, BASH_PARSE_OPTIONS); + if (!parsed.ok || parsed.hasError) { + return normalizedCwdArg === undefined + ? { dirs: [], selfKnown } + : { dirs: [effectiveCwd], selfKnown }; + } + const targets = extractBashTargetDirs( + parsed.root, + effectiveCwd, + this.env.homeDir, + ).map((target) => hostPath(target, this.env.pathClass)); + if (normalizedCwdArg !== undefined && !targets.includes(effectiveCwd)) { + targets.unshift(effectiveCwd); + } + return { dirs: targets, selfKnown }; + } + default: + return { dirs: [], selfKnown }; + } + } + + private targetDirsFromAccesses(ctx: ToolDidExecuteContext): { + dirs: string[]; + selfKnown: string[]; + } { + const dirs: string[] = []; + const selfKnown: string[] = []; + const targetsFiles = + ctx.toolCall.name === 'Read' || + ctx.toolCall.name === 'Edit' || + ctx.toolCall.name === 'Write'; + for (const access of ctx.accesses ?? []) { + if (access.kind !== 'file') continue; + if ( + targetsFiles && + ctx.result.isError !== true && + AGENTS_MD_BASENAMES.has(basename(access.path)) + ) { + selfKnown.push(access.path); + } + dirs.push(targetsFiles ? dirname(access.path) : access.path); + } + return { dirs: [...new Set(dirs)], selfKnown: [...new Set(selfKnown)] }; + } + + private async probeDir(dir: string): Promise { + const anchor = await this.nearestExistingDir(dir); + if (anchor === undefined) return []; + const deps = { fs: this.fs }; + const projectRoot = await findProjectRoot(deps, anchor); + const chain = dirsRootToLeaf(anchor, projectRoot); + const found: string[] = []; + for (const chainDir of chain) { + const candidates = agentsMdCandidatePaths(chainDir); + if (candidates.every((candidate) => this.known.has(normalize(candidate)))) continue; + for (const path of await findAgentsMdInDir(deps, chainDir)) { + found.push(normalize(path)); + } + } + return found; + } + + private async nearestExistingDir(path: string): Promise { + let current = path; + for (;;) { + const stat = await this.fs.stat(current).catch(() => undefined); + if (stat?.isDirectory === true) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } + } +} + +function hostPath(path: string, pathClass: 'posix' | 'win32'): string { + return normalize(normalizeUserPath(path, pathClass)); +} + +function stringArg(args: unknown, key: string): string | undefined { + if (typeof args !== 'object' || args === null) return undefined; + const value = (args as Record)[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function reminderText(paths: readonly string[]): string { + return ( + '\n' + + 'The path(s) touched by this call are covered by AGENTS.md instruction file(s) that were not part of the injected instructions:\n' + + paths.map((path) => `- ${path}`).join('\n') + + '\nRead them before making changes in those directories. Each file is suggested at most once per agent.' + + '\n\n\n' + ); +} + +function prependReminder(result: ExecutableToolResult, text: string): ExecutableToolResult { + const output = result.output; + let newOutput: ExecutableToolOutput; + if (typeof output === 'string') { + newOutput = text + output; + } else { + const parts: ContentPart[] = [...output]; + const first = parts[0]; + if (first !== undefined && first.type === 'text') { + parts[0] = { type: 'text', text: text + first.text }; + } else { + parts.unshift({ type: 'text', text }); + } + newOutput = parts; + } + return result.isError === true + ? { ...result, output: newOutput, isError: true } + : { ...result, output: newOutput }; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentAgentsMdReminderService, + AgentAgentsMdReminderService, + ScopeActivation.OnScopeCreated, + 'agentsMdReminder', +); diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/bashTargets.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/bashTargets.ts new file mode 100644 index 00000000000..eed1b7727f4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/bashTargets.ts @@ -0,0 +1,249 @@ +/** + * `agentsMdReminder` domain — Bash-command directory extraction. + * + * Statically extracts the directories a Bash tool call is going to inspect, + * walking the `bashParser` syntax tree: the literal operands of + * directory-listing commands (`ls` / `tree` / `find` / `dir` / `exa` / `eza` / + * `lsd`), with literal `cd` commands rebasing relative resolution as they + * appear (`cd packages && ls kap-server`) and a genuinely operand-less + * listing command listing the current base (one whose operands all failed + * resolution is skipped instead). Only top-level simple commands are read — + * anything not statically resolvable (expansions, command + * substitution, glob characters (quoted or not), `~`, quoting mixes, compound + * constructs, `cd -`, a `cd` inside a pipeline, or a listing command invoked + * through a path prefix like `./ls` whose semantics are unknown) is skipped, + * and a `cd` whose operand cannot be resolved poisons relative resolution + * (never guesses a base) until an absolute `cd` re-anchors. Flags are dropped + * together with the arguments of the known argument-taking options + * (`ls --sort size`), and `find` collects leading paths past its no-argument + * global options (`find -L packages`) before stopping at the expression. + * A missed directory is recovered by the later Read/Edit/Write + * probes; a wrong one is not, so skipping always wins over guessing. + */ + +import { isAbsolute, join, normalize } from 'pathe'; + +import type { BashSyntaxNode } from '#/app/bashParser/bashParser'; + +const LISTING_COMMANDS: ReadonlySet = new Set([ + 'ls', + 'tree', + 'find', + 'dir', + 'exa', + 'eza', + 'lsd', +]); + +const TRANSPARENT_WRAPPERS: ReadonlySet = new Set([ + 'program', + 'list', + 'pipeline', + 'redirected_statement', +]); + +const LS_ARG_TAKING_OPTIONS: ReadonlySet = new Set([ + '-w', + '--width', + '--sort', + '--block-size', + '-I', + '--ignore', + '--hide', + '--format', + '--time-style', + '--indicator-style', + '--quoting-style', + '-T', + '--tabsize', +]); + +const TREE_LIKE_ARG_TAKING_OPTIONS: ReadonlySet = new Set([ + '-w', + '--width', + '--sort', + '--block-size', + '-I', + '--ignore', + '--ignore-glob', + '--hide', + '--format', + '--time-style', + '--indicator-style', + '--hyperlink', + '--quoting-style', + '-T', + '--tabsize', + '-L', + '--level', + '--depth', + '-P', + '-o', + '--filelimit', + '--charset', + '--timefmt', + '-s', +]); + +const FIND_GLOBAL_OPTIONS: ReadonlySet = new Set(['-H', '-L', '-P']); + +const UNSAFE_OPERAND = /[$`*?[\]~]/; + +export function extractBashTargetDirs( + root: BashSyntaxNode, + cwd: string, + homeDir: string, +): string[] { + const commands: CollectedCommand[] = []; + collectCommands(root, commands); + + const targets: string[] = []; + const seen = new Set(); + let base: string | undefined = cwd; + const push = (operand: string): void => { + let dir: string; + if (isAbsolute(operand)) { + dir = normalize(operand); + } else { + const currentBase = base; + if (currentBase === undefined) return; + dir = normalize(join(currentBase, operand)); + } + if (seen.has(dir)) return; + seen.add(dir); + targets.push(dir); + }; + + for (const { node, inPipeline } of commands) { + const { name, args, dropped } = commandNameAndArgs(node); + if (name === undefined) continue; + if (name === 'cd') { + if (inPipeline) continue; + if (dropped) { + base = undefined; + continue; + } + const target = args[0]; + if (args.length === 1 && target !== undefined && !target.startsWith('-')) { + if (isAbsolute(target)) { + base = normalize(target); + } else if (base !== undefined) { + base = normalize(join(base, target)); + } + } else if (args.length === 0) { + base = homeDir; + } else { + base = undefined; + } + continue; + } + if (!LISTING_COMMANDS.has(name)) continue; + const operands = name === 'find' ? takeLeadingPaths(args) : dropFlags(args, name); + if (operands.length === 0) { + if (!dropped) push('.'); + continue; + } + for (const operand of operands) push(operand); + } + return targets; +} + +interface CollectedCommand { + readonly node: BashSyntaxNode; + readonly inPipeline: boolean; +} + +function collectCommands(node: BashSyntaxNode, out: CollectedCommand[], inPipeline = false): void { + if (node.type === 'command') { + out.push({ node, inPipeline }); + return; + } + if (!TRANSPARENT_WRAPPERS.has(node.type)) return; + const nested = inPipeline || node.type === 'pipeline'; + for (const child of node.children) collectCommands(child, out, nested); +} + +function commandNameAndArgs(command: BashSyntaxNode): { + name: string | undefined; + args: string[]; + dropped: boolean; +} { + const nameIndex = command.children.findIndex((child) => child.type === 'command_name'); + const nameNode = nameIndex >= 0 ? command.children[nameIndex] : undefined; + const nameWord = nameNode?.children.find((child) => child.isNamed); + const rawName = nameWord === undefined ? undefined : literalText(nameWord); + if (rawName === undefined || rawName.length === 0 || rawName.includes('/')) { + return { name: undefined, args: [], dropped: false }; + } + const args: string[] = []; + let dropped = false; + for (const child of command.children.slice(nameIndex + 1)) { + const value = literalText(child); + if (value === undefined) { + dropped = true; + } else if (value.length > 0) { + args.push(value); + } + } + return { name: rawName, args, dropped }; +} + +function literalText(node: BashSyntaxNode): string | undefined { + switch (node.type) { + case 'word': { + const raw = node.text; + if (UNSAFE_OPERAND.test(raw)) return undefined; + const unescaped = raw.replaceAll(/\\(.)/gs, '$1'); + return UNSAFE_OPERAND.test(unescaped) ? undefined : unescaped; + } + case 'number': + return node.text; + case 'raw_string': { + if (node.text.length < 2) return undefined; + const value = node.text.slice(1, -1); + return UNSAFE_OPERAND.test(value) ? undefined : value; + } + case 'string': { + let value = ''; + for (const child of node.children) { + if (child.type === 'string_content') { + value += child.text; + } else if (child.isNamed) { + return undefined; + } + } + return UNSAFE_OPERAND.test(value) ? undefined : value; + } + default: + return undefined; + } +} + +function dropFlags(args: readonly string[], command: string): string[] { + const argumentTakingOptions = + command === 'ls' || command === 'dir' + ? LS_ARG_TAKING_OPTIONS + : TREE_LIKE_ARG_TAKING_OPTIONS; + const out: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (!arg.startsWith('-')) { + out.push(arg); + continue; + } + if (!arg.includes('=') && argumentTakingOptions.has(arg)) { + i += 1; + } + } + return out; +} + +function takeLeadingPaths(args: readonly string[]): string[] { + const out: string[] = []; + for (const arg of args) { + if (FIND_GLOBAL_OPTIONS.has(arg) || arg === '--') continue; + if (arg.startsWith('-') || arg === '(' || arg === ')' || arg === '!') break; + out.push(arg); + } + return out; +} diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts index 5c59841303a..d649c56daa4 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -16,9 +16,17 @@ * The combined AGENTS.md content is injected in full; when it exceeds the * soft {@link AGENTS_MD_RECOMMENDED_MAX_BYTES} budget a visible * `agentsMdWarning` is produced instead of silently truncating. + * + * The discovered-file list is returned alongside the content as `paths` + * (surfaced as `agentsMdPaths`), and the per-directory candidate rules + * (`AGENTS_MD_PLAIN_NAMES` / `dotKimiAgentsMdPath` / `findAgentsMdInDir`) + * plus the root→leaf chain helpers (`findProjectRoot` / `dirsRootToLeaf`) + * are exported so discovery probes and injection never drift apart. Legacy + * restored prompts can recover their exact injected paths from the same + * rendered source annotations. */ -import { dirname, join, normalize } from 'pathe'; +import { basename, dirname, join, normalize } from 'pathe'; import { findGitWorkTree } from '#/app/git/workTree'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -40,6 +48,7 @@ export type { ProfileContextDeps }; export interface PreparedSystemPromptContext extends SystemPromptContext { readonly cwdListing?: string; readonly agentsMd?: string; + readonly agentsMdPaths?: readonly string[]; readonly additionalDirsInfo?: string; readonly agentsMdWarning?: string; } @@ -66,6 +75,7 @@ export async function prepareSystemPromptContext( return { cwdListing, agentsMd: agentsMdResult.content, + agentsMdPaths: agentsMdResult.paths, additionalDirsInfo, agentsMdWarning: agentsMdResult.warning, }; @@ -80,12 +90,77 @@ export async function loadAgentsMd( return result.content; } -interface LoadedAgentsMd { +export async function loadAgentsMdDetailed( + deps: ProfileContextDeps, + workDir: string, + brandHome?: string, +): Promise { + return loadAgentsMdForRoots(deps, brandHome, [workDir]); +} + +export interface LoadedAgentsMd { readonly content: string; readonly warning: string | undefined; + readonly paths: readonly string[]; +} + +export const AGENTS_MD_PLAIN_NAMES = ['AGENTS.md', 'agents.md'] as const; + +export function dotKimiAgentsMdPath(dir: string): string { + return join(dir, '.kimi-code', 'AGENTS.md'); +} + +export function agentsMdCandidatePaths(dir: string): string[] { + return [dotKimiAgentsMdPath(dir), ...AGENTS_MD_PLAIN_NAMES.map((name) => join(dir, name))]; +} + +export function extractAgentsMdPathsFromSystemPrompt(systemPrompt: string): string[] { + const paths: string[] = []; + const seen = new Set(); + for (const match of systemPrompt.matchAll(/^$/gm)) { + const path = match[1]; + if ( + path === undefined || + !AGENTS_MD_PLAIN_NAMES.some((candidate) => candidate === basename(path)) + ) { + continue; + } + const normalized = normalize(path); + if (seen.has(normalized)) continue; + seen.add(normalized); + paths.push(normalized); + } + return paths; +} + +export async function findAgentsMdInDir( + deps: { readonly fs: IHostFileSystem }, + dir: string, +): Promise { + const found: string[] = []; + const dotKimi = dotKimiAgentsMdPath(dir); + if (await isNonEmptyFile(deps, dotKimi)) found.push(dotKimi); + for (const fileName of AGENTS_MD_PLAIN_NAMES) { + const candidate = join(dir, fileName); + if (await isNonEmptyFile(deps, candidate)) { + found.push(candidate); + break; + } + } + return found; } -export type { LoadedAgentsMd }; +async function isNonEmptyFile( + deps: { readonly fs: IHostFileSystem }, + path: string, +): Promise { + try { + const content = await deps.fs.readText(path, { errors: 'ignore' }); + return content.trim().length > 0; + } catch { + return false; + } +} export async function loadAgentsMdForRoots( deps: ProfileContextDeps, @@ -115,7 +190,7 @@ export async function loadAgentsMdForRoots( const genericDirs = [join(realHome, '.agents')]; const genericFiles = genericDirs.flatMap((dir) => - ['AGENTS.md', 'agents.md'].map((name) => join(dir, name)), + AGENTS_MD_PLAIN_NAMES.map((name) => join(dir, name)), ); for (const file of genericFiles) { if (await collect(file)) break; @@ -127,8 +202,8 @@ export async function loadAgentsMdForRoots( const dirs = dirsRootToLeaf(rootWorkDir, projectRoot); for (const dir of dirs) { - await collect(join(dir, '.kimi-code', 'AGENTS.md')); - for (const fileName of ['AGENTS.md', 'agents.md']) { + await collect(dotKimiAgentsMdPath(dir)); + for (const fileName of AGENTS_MD_PLAIN_NAMES) { if (await collect(join(dir, fileName))) break; } } @@ -144,7 +219,8 @@ export async function loadAgentsMdForRoots( ); } const warning = loadWarnings.length > 0 ? loadWarnings.join('\n') : undefined; - return { content, warning }; + const paths = discovered.map((file) => normalize(file.path)); + return { content, warning, paths }; } export interface AgentsMdWatchRoot { @@ -193,7 +269,15 @@ async function loadAdditionalDirsInfo( return sections.join('\n\n'); } -function dirsRootToLeaf(workDir: string, projectRoot: string): string[] { +export async function findProjectRoot( + deps: { readonly fs: IHostFileSystem }, + workDir: string, +): Promise { + const rootWorkDir = normalize(workDir); + return (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir; +} + +export function dirsRootToLeaf(workDir: string, projectRoot: string): string[] { const dirs: string[] = []; let current = normalize(workDir); @@ -235,7 +319,7 @@ async function readAgentFile( return { path, content }; } -async function pathExists(deps: ProfileContextDeps, path: string): Promise { +async function pathExists(deps: { readonly fs: IHostFileSystem }, path: string): Promise { try { await deps.fs.lstat(path); return true; @@ -244,11 +328,11 @@ async function pathExists(deps: ProfileContextDeps, path: string): Promise { +async function entryExists(deps: { readonly fs: IHostFileSystem }, path: string): Promise { return pathExists(deps, path); } -async function isFile(deps: ProfileContextDeps, path: string): Promise { +async function isFile(deps: { readonly fs: IHostFileSystem }, path: string): Promise { try { const stat = await deps.fs.stat(path); return stat.isFile; diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index d19feb27468..755b9709e9e 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -54,11 +54,13 @@ export type AgentConfigUpdateData = Partial<{ export interface SystemPromptContext extends AgentProfileContext { readonly agentsMdWarning?: string; + readonly agentsMdPaths?: readonly string[]; } export type ResolvedAgentProfile = AgentProfile; export interface ProfileData extends AgentConfigData { + readonly agentsMdPaths?: readonly string[]; readonly activeToolNames?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; @@ -69,6 +71,7 @@ export type ProfileUpdateData = Partial<{ profileName: string; thinkingLevel: string; systemPrompt: string; + agentsMdPaths: readonly string[]; disallowedTools: readonly string[]; activeToolNames: readonly string[]; }>; @@ -78,6 +81,7 @@ export interface ProfileBindingSnapshot { readonly profileName?: string; readonly thinkingLevel: string; readonly systemPrompt: string; + readonly agentsMdPaths?: readonly string[]; readonly activeToolNames?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index f525fc216f6..fee2e26e28c 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -3,10 +3,11 @@ * Op (`configUpdate`) for the agent's persistent configuration slice. * * Declares the persistent profile config — `modelAlias`, `profileName`, - * the resolved base thinking effort, `systemPrompt`, and the profile - * `disallowedTools` denylist and `subagents` delegation allowlist — as a wire - * Model (initial `defaultProfileModel()`), plus the single Op whose `apply` is - * a pure merge of an already-resolved payload. Live records carry + * the resolved base thinking effort, `systemPrompt`, its injected AGENTS.md + * path provenance, and the profile `disallowedTools` denylist and `subagents` + * delegation allowlist — as a wire Model (initial `defaultProfileModel()`), + * plus the single Op whose `apply` is a pure merge of an already-resolved + * payload. Live records carry * `thinkingEffort` (matching the v1 wire field); legacy replay still accepts * `thinkingLevel`. The value is * resolved to a `ThinkingEffort` at the call site and carried in the @@ -48,6 +49,7 @@ export interface ProfileModelState { readonly profileName?: string; readonly thinkingLevel: string; readonly systemPrompt: string; + readonly agentsMdPaths?: readonly string[]; readonly disallowedTools?: readonly string[]; readonly subagents?: readonly string[]; } @@ -63,6 +65,7 @@ export const profileBind = ProfileModel.defineOp('profile.bind', { profileName: z.string().optional(), thinkingEffort: z.custom(), systemPrompt: z.string(), + agentsMdPaths: z.array(z.string()).readonly().optional(), activeToolNames: z.array(z.string()).readonly().optional(), disallowedTools: z.array(z.string()).readonly(), subagents: z.array(z.string()).readonly().optional(), @@ -72,6 +75,7 @@ export const profileBind = ProfileModel.defineOp('profile.bind', { profileName: p.profileName ?? s.profileName, thinkingLevel: p.thinkingEffort, systemPrompt: p.systemPrompt, + agentsMdPaths: p.agentsMdPaths ?? s.agentsMdPaths, disallowedTools: p.disallowedTools, subagents: p.subagents, }), @@ -84,6 +88,7 @@ export const configUpdate = ProfileModel.defineOp('config.update', { thinkingEffort: z.custom().optional(), thinkingLevel: z.custom().optional(), systemPrompt: z.string().optional(), + agentsMdPaths: z.array(z.string()).readonly().optional(), disallowedTools: z.array(z.string()).readonly().optional(), }), apply: (s, p) => { @@ -101,6 +106,12 @@ export const configUpdate = ProfileModel.defineOp('config.update', { if (p.systemPrompt !== undefined && p.systemPrompt !== s.systemPrompt) { next = { ...(next ?? s), systemPrompt: p.systemPrompt }; } + if ( + p.agentsMdPaths !== undefined && + !stringArrayEqual(p.agentsMdPaths, s.agentsMdPaths) + ) { + next = { ...(next ?? s), agentsMdPaths: p.agentsMdPaths }; + } if ( p.disallowedTools !== undefined && !stringArrayEqual(p.disallowedTools, s.disallowedTools) diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 9e4b99cac88..f510eaa7c80 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -7,8 +7,9 @@ * (`resolveRequestParams`: cache key / sampling / thinking effort+keep — * wire encoding is each dialect's own hook), persists the profile binding * (`cwd` / `modelAlias` / `profileName` / resolved base `thinkingLevel` / - * `systemPrompt` / `activeToolNames` / profile `disallowedTools` / profile - * `subagents`) in the `wire` `ProfileModel` through the `profile.bind` Op + * `systemPrompt` / injected AGENTS.md paths / `activeToolNames` / profile + * `disallowedTools` / profile `subagents`) in the `wire` `ProfileModel` through + * the `profile.bind` Op * (later slice updates ride the `config.update` Op) and the persisted * active-tool set in the `wire` `ActiveToolsModel` through the * `tools.set_active_tools` / `tools.reset_active_tools` Ops (`wire.dispatch`), @@ -57,7 +58,10 @@ * (`IAgentStateService`) and read/written through it; `optionsValue` (holds * the `cwd` / `emitStatusUpdated` callbacks) and `activeProfile` * (a `ResolvedAgentProfile` carrying the `systemPrompt` function) stay plain - * fields because the container only holds pure data structures. Bound at + * fields because the container only holds pure data structures. After every + * successful bind / apply / refresh (never before the new prompt commits, + * so a failed build cannot poison the set), the injected AGENTS.md paths are + * seeded into `agentsMdReminder`'s known-set with the effective cwd. Bound at * Agent scope. */ @@ -101,13 +105,18 @@ import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionT import { IPluginService } from '#/app/plugin/plugin'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; 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 { IWireService } from '#/wire/wire'; import type { PayloadOf } from '#/wire/types'; import { IEventBus } from '#/app/event/eventBus'; -import { prepareSystemPromptContext, type LoadedAgentsMd } from './context'; +import { + extractAgentsMdPathsFromSystemPrompt, + prepareSystemPromptContext, + type LoadedAgentsMd, +} from './context'; import type { ApplyProfileOptions, BindAgentInput, @@ -220,6 +229,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader, @IAgentStateService private readonly states: IAgentStateService, @IPluginService private readonly plugins: IPluginService, + @IAgentAgentsMdReminderService private readonly agentsMdReminder: IAgentAgentsMdReminderService, ) { super(); this.states.register(profileActiveToolNamesOverlayKey); @@ -309,12 +319,15 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ applyBindingSnapshot(snapshot: ProfileBindingSnapshot): void { this.activeProfile = undefined; this.activeToolNamesOverlay = undefined; + const agentsMdPaths = + snapshot.agentsMdPaths ?? extractAgentsMdPathsFromSystemPrompt(snapshot.systemPrompt); this.wire.dispatch( profileBind({ modelAlias: snapshot.modelAlias, profileName: snapshot.profileName, thinkingEffort: snapshot.thinkingLevel, systemPrompt: snapshot.systemPrompt, + agentsMdPaths, activeToolNames: snapshot.activeToolNames, disallowedTools: snapshot.disallowedTools ?? [], subagents: snapshot.subagents, @@ -325,8 +338,10 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ profileName: snapshot.profileName, thinkingLevel: snapshot.thinkingLevel, systemPrompt: snapshot.systemPrompt, + agentsMdPaths, disallowedTools: snapshot.disallowedTools ?? [], }); + this.agentsMdReminder.seedInjected(agentsMdPaths, this.sessionContext.cwd); } async bind(input: BindAgentInput): Promise { @@ -376,6 +391,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ profileName: profile.name, thinkingEffort: thinkingLevel, systemPrompt, + agentsMdPaths: context.agentsMdPaths ?? [], activeToolNames: profile.tools, disallowedTools: profile.disallowedTools ?? [], subagents: profile.subagents, @@ -387,6 +403,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ systemPrompt, disallowedTools: profile.disallowedTools ?? [], }); + this.seedAgentsMdReminder(context); this.publishAgentsMdWarning(); this.publishToolPatternWarnings(profile); @@ -446,6 +463,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.update({ profileName: profile.name, systemPrompt: profile.systemPrompt(context), + agentsMdPaths: context.agentsMdPaths ?? [], disallowedTools: profile.disallowedTools ?? [], }); this.setActiveTools(profile.tools); @@ -454,6 +472,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise { const context = await this.buildSystemPromptContext(profile, options); this.useProfile(profile, context); + this.seedAgentsMdReminder(context); this.cacheAgentsMdWarning(context); this.publishAgentsMdWarning(); this.publishToolPatternWarnings(profile); @@ -478,11 +497,20 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.update({ profileName: profile.name, systemPrompt: profile.systemPrompt(context), + agentsMdPaths: context.agentsMdPaths ?? [], }); + this.seedAgentsMdReminder(context); this.cacheAgentsMdWarning(context); this.publishAgentsMdWarning(); } + private seedAgentsMdReminder(context: SystemPromptContext): void { + this.agentsMdReminder.seedInjected( + context.agentsMdPaths ?? [], + context.cwd ?? this.sessionContext.cwd, + ); + } + getAgentsMdWarning(): string | undefined { return this.agentsMdWarning; } @@ -495,6 +523,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ profileName: this.profileName, thinkingLevel: this.thinkingLevel, systemPrompt: this.systemPrompt, + agentsMdPaths: this.profileState.agentsMdPaths, activeToolNames: this.activeToolNames === undefined ? undefined : [...this.activeToolNames], disallowedTools: [...(this.profileState.disallowedTools ?? [])], subagents: @@ -598,6 +627,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ payload.thinkingEffort = this.resolveThinkingEffort(requested, model); } if (changed.systemPrompt !== undefined) payload.systemPrompt = changed.systemPrompt; + if (changed.agentsMdPaths !== undefined) { + payload.agentsMdPaths = [...changed.agentsMdPaths]; + } if (changed.disallowedTools !== undefined) { payload.disallowedTools = [...changed.disallowedTools]; } @@ -879,6 +911,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return { content: this.instructions.agentsMd ?? '', warning: this.instructions.agentsMdWarning, + paths: this.instructions.agentsMdPaths ?? [], }; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 00c10d11c9c..17b6b8defba 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -44,6 +44,7 @@ import type { BeforeToolExecuteEvent, ResolvedToolExecutionHookContext, ToolDidExecuteContext, + ToolExecutionOutcome, WillExecuteToolEvent, } from '#/agent/toolExecutor/toolHooks'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -74,12 +75,18 @@ const validators = new WeakMap(); export interface ToolExecutionTask { readonly accesses: ToolAccesses; - readonly execute: (signal: AbortSignal) => Promise; + readonly execute: (signal: AbortSignal) => Promise; +} + +export interface ToolExecutionRunResult { + readonly result: ToolResult; + readonly outcome: ToolExecutionOutcome; } interface TimedToolResult { readonly index: number; readonly result: ToolResult; + readonly outcome: ToolExecutionOutcome; readonly durationMs: number; } @@ -200,13 +207,15 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { const preparedTasks: Array<{ task: ToolExecutionTask; call: PreflightedToolCall; + resolvedAccesses?: ToolAccesses; stopBatchAfterThis?: boolean; }> = []; let stopBatch = false; for (const call of preflighted) { if (stopBatch) { - preparedTasks.push({ task: this.prepareSkippedToolCall(call, options), call }); + const skipped = this.prepareSkippedToolCall(call, options); + preparedTasks.push({ ...skipped, call }); continue; } @@ -214,6 +223,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { preparedTasks.push({ task: prepared.task, call, + resolvedAccesses: prepared.resolvedAccesses, stopBatchAfterThis: prepared.stopBatchAfterThis, }); if (prepared.stopBatchAfterThis === true) { @@ -285,13 +295,20 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { private async finalizeTimedResult( prepared: { readonly call: PreflightedToolCall; + readonly resolvedAccesses?: ToolAccesses; }, timedResult: TimedToolResult, options: ToolExecutorExecuteOptions, ): Promise { const { call } = prepared; const rawResult = timedResult.result; - const finalized = await this.finalizeToolResult(call, rawResult, options); + const finalized = await this.finalizeToolResult( + call, + rawResult, + options, + timedResult.outcome, + prepared.resolvedAccesses, + ); this.dispatchToolResult(call, finalized, options); this.trackToolCall(call, finalized, timedResult.durationMs, options); @@ -332,22 +349,25 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { options: ToolExecutorExecuteOptions, ): Promise<{ task: ToolExecutionTask; + resolvedAccesses?: ToolAccesses; stopBatchAfterThis?: boolean; }> { const settleError = ( args: unknown, output: string, + outcome: Exclude, displayFields?: ToolCallDisplayFields, ): { task: ToolExecutionTask } => { this.dispatchToolCall(call, args, options, displayFields); return { - task: makeResolvedTask(makeErrorToolResult(call, args, output)), + task: makeResolvedTask(makeErrorToolResult(call, args, output), outcome), }; }; const settleSynthetic = ( args: unknown, result: ExecutableToolResult, + outcome: Exclude, displayFields?: ToolCallDisplayFields, ): { task: ToolExecutionTask; @@ -356,19 +376,22 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { const toolResult = this.normalizeAndMergeResult(result, call.toolName, undefined); this.dispatchToolCall(call, args, options, displayFields); return { - task: makeResolvedTask({ - toolCall: call.toolCall, - toolName: call.toolName, - args, - result: toolResult, - stopTurn: toolResult.stopTurn === true, - }), + task: makeResolvedTask( + { + toolCall: call.toolCall, + toolName: call.toolName, + args, + result: toolResult, + stopTurn: toolResult.stopTurn === true, + }, + outcome, + ), stopBatchAfterThis: toolResult.stopBatchAfterThis ?? toolResult.stopTurn, }; }; if (call.kind === 'rejected') { - return settleError(call.args, call.output); + return settleError(call.args, call.output, 'preflight-rejected'); } let execution: ToolExecution; @@ -379,7 +402,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { error instanceof PathSecurityError ? error.message : `Tool "${call.toolName}" failed to resolve execution: ${errorMessage(error)}`; - return settleError(call.args, output); + return settleError(call.args, output, 'resolution-failed'); } const displayFields = toolCallDisplayFieldsFromExecution(execution); @@ -388,19 +411,20 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { return settleError( call.args, abortedToolOutput(call.toolName, options.signal), + 'aborted', displayFields, ); } if (execution.isError === true) { - return settleSynthetic(call.args, execution, displayFields); + return settleSynthetic(call.args, execution, 'synthetic', displayFields); } const beforeContext = buildBeforeExecuteContext(call, execution, allCalls, options); const decision = await this.beforeExecuteEmitter.fireBeforeExecute(beforeContext); if (decision?.veto !== undefined) { - return settleSynthetic(call.args, decision.veto, displayFields); + return settleSynthetic(call.args, decision.veto, 'vetoed', displayFields); } const executionMetadata = decision?.executionMetadata; @@ -423,6 +447,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { execute: async (taskSignal) => this.runSingleExecution(call, execution, executionMetadata, options, taskSignal), }, + resolvedAccesses: execution.accesses, stopBatchAfterThis: execution.stopBatchAfterThis, }; } @@ -430,10 +455,12 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { private prepareSkippedToolCall( call: PreflightedToolCall, options: ToolExecutorExecuteOptions, - ): ToolExecutionTask { + ): { task: ToolExecutionTask } { const output = 'Tool skipped because a previous tool call stopped the turn.'; this.dispatchToolCall(call, call.args, options); - return makeResolvedTask(makeErrorToolResult(call, call.args, output)); + return { + task: makeResolvedTask(makeErrorToolResult(call, call.args, output), 'skipped'), + }; } private async *executeBatch( @@ -451,9 +478,10 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { start: async () => { const startedAt = Date.now(); return { - result: task.execute(signal).then((result) => ({ + result: task.execute(signal).then(({ result, outcome }) => ({ index, result, + outcome, durationMs: Math.max(0, Date.now() - startedAt), })), }; @@ -488,13 +516,16 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { metadata: unknown, options: ToolExecutorExecuteOptions, signal: AbortSignal, - ): Promise { + ): Promise { if (signal.aborted) { - return makeErrorToolResult( - call, - call.args, - abortedToolOutput(call.toolName, signal), - ).result; + return { + result: makeErrorToolResult( + call, + call.args, + abortedToolOutput(call.toolName, signal), + ).result, + outcome: 'aborted', + }; } let rawResult: ExecutableToolResult; @@ -516,10 +547,16 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { const output = aborted ? abortedToolOutput(call.toolName, signal) : `Tool "${call.toolName}" failed: ${errorMessage(error)}`; - return makeErrorToolResult(call, call.args, output).result; + return { + result: makeErrorToolResult(call, call.args, output).result, + outcome: 'executed', + }; } - return this.normalizeAndMergeResult(rawResult, call.toolName, execution); + return { + result: this.normalizeAndMergeResult(rawResult, call.toolName, execution), + outcome: 'executed', + }; } private normalizeAndMergeResult( @@ -592,6 +629,8 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { call: PreflightedToolCall, result: ToolResult, options: ToolExecutorExecuteOptions, + outcome: ToolExecutionOutcome, + resolvedAccesses?: ToolAccesses, ): Promise { const didCtx: ToolDidExecuteContext = { turnId: options.turnId, @@ -601,6 +640,8 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { toolCalls: [call.toolCall], tool: call.kind === 'runnable' ? call.tool : undefined, args: call.args, + outcome, + accesses: resolvedAccesses, result: result as ExecutableToolResult, }; @@ -775,10 +816,13 @@ function toolCallDisplayFieldsFromExecution( }; } -function makeResolvedTask(result: PreparedToolResult): ToolExecutionTask { +function makeResolvedTask( + result: PreparedToolResult, + outcome: ToolExecutionOutcome, +): ToolExecutionTask { return { accesses: ToolAccesses.none(), - execute: async () => result.result, + execute: async () => ({ result: result.result, outcome }), }; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts index 202097050b9..5e86954f3cd 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts @@ -18,9 +18,11 @@ * `waitUntil(promise)`; the executor awaits all of them before dispatching * an allowed call (e.g. MCP initial load). * - `hooks.onDidExecuteTool` (ordered hook slot, `ToolDidExecuteContext`): - * post-execution result finalization, kept as an `OrderedHookSlot`. Every - * call reaches it — including preflight-rejected ones (missing/unavailable - * tool, guard denial, invalid args), which arrive without `tool` set. + * post-execution result finalization with the resolved execution's canonical + * resource accesses and an outcome describing whether the execution callback + * actually ran, kept as an `OrderedHookSlot`. Every call reaches it — + * including preflight-rejected ones (missing/unavailable tool, guard denial, + * invalid args), which arrive without `tool` or `accesses` set. * * Pure contract (types only); no scoped service. */ @@ -29,7 +31,12 @@ import type { IWaitUntil } from '#/_base/event'; import type { ToolCall } from '#/kosong/contract/message'; import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; -import type { ExecutableTool, ExecutableToolResult, RunnableToolExecution } from '#/tool/toolContract'; +import type { + ExecutableTool, + ExecutableToolResult, + RunnableToolExecution, + ToolAccesses, +} from '#/tool/toolContract'; export interface ToolExecutionHookContext { readonly turnId: number; @@ -64,7 +71,18 @@ export interface WillExecuteToolEvent extends IWaitUntil { readonly args: unknown; } +export type ToolExecutionOutcome = + | 'executed' + | 'preflight-rejected' + | 'resolution-failed' + | 'vetoed' + | 'aborted' + | 'synthetic' + | 'skipped'; + export interface ToolDidExecuteContext extends ToolExecutionHookContext { + readonly outcome: ToolExecutionOutcome; + readonly accesses?: ToolAccesses; result: ExecutableToolResult; stopTurn?: boolean; } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index c534d23e178..f9cd96f0d0c 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -323,6 +323,13 @@ export interface ToolCallRepeatEvent { trace_id?: string; } +export interface AgentsMdReminderShownEvent { + turn_id: number; + tool_name: string; + reminded_count: number; + trace_id?: string; +} + export interface GrepToolRgFallbackEvent { source?: 'share-bin-cached' | 'vendor' | 'share-bin-downloaded'; outcome: 'resolved' | 'failed'; @@ -788,6 +795,17 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced the repeated tool call; absent for non-Kimi protocols', }, }), + agents_md_reminder_shown: defineAgentTelemetryEvent({ + owner: 'kimi-code', + comment: 'An AGENTS.md discovery reminder is appended to a tool result.', + properties: { + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + tool_name: 'Registered tool name whose result carried the reminder', + reminded_count: 'Number of AGENTS.md paths listed in the reminder', + trace_id: + 'Trace id of the LLM request that produced the tool call; absent for non-Kimi protocols', + }, + }), grep_tool_rg_fallback: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The grep tool falls back when resolving ripgrep.', diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 024343c856d..0390adbfa5e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -267,6 +267,8 @@ export * from '#/agent/usage/usage'; export * from '#/agent/usage/usageService'; export * from '#/agent/toolDedupe/toolDedupe'; export * from '#/agent/toolDedupe/toolDedupeService'; +export * from '#/agent/agentsMdReminder/agentsMdReminder'; +export * from '#/agent/agentsMdReminder/agentsMdReminderService'; import '#/agent/toolSelect/flag'; export * from '#/agent/tools/select-tools/select-tools'; import '#/agent/tools/select-tools/selectToolsTool'; diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts index fccb1f869b5..05919d379b9 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts @@ -8,8 +8,9 @@ * main agent's record stream so the UI shows the nested transcript and the * `subagent.*` records fire. Once the * subagent finishes, reloads `AGENTS.md` through the `profile` context helper - * (over the os `hostFs` + host home dir, with the `bootstrap` brand dir) and - * appends an `init`-variant system reminder to the main agent via + * (over the os `hostFs` + host home dir, with the `bootstrap` brand dir), + * re-seeds the main agent's `agentsMdReminder` known-set with the reloaded + * paths, and appends an `init`-variant system reminder to the main agent via * `systemReminder`, then flushes the main agent's wire journal. Bound at * Session scope. * @@ -27,7 +28,8 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { loadAgentsMd } from '#/agent/profile/context'; +import { loadAgentsMdDetailed } from '#/agent/profile/context'; +import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IWireService } from '#/wire/wire'; @@ -105,11 +107,14 @@ export class SessionInitService implements ISessionInitService { cancel: (reason) => controller.abort(reason), }); - const agentsMd = await loadAgentsMd( + const { content: agentsMd, paths: agentsMdPaths } = await loadAgentsMdDetailed( { fs: this.fs, homeDir: this.env.homeDir }, this.sessionContext.cwd, this.bootstrap.homeDir, ); + main.accessor + .get(IAgentAgentsMdReminderService) + .seedInjected(agentsMdPaths, this.sessionContext.cwd); main.accessor .get(IAgentSystemReminderService) .appendSystemReminder(initCompletionReminder(agentsMd), { diff --git a/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts index d19f5005558..12aa9ff7a67 100644 --- a/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts +++ b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts @@ -2,10 +2,10 @@ * `sessionInstructions` domain — seeded AGENTS.md provider contract. * * Defines `ISessionInstructionsProvider`, the pure-data injection contract - * carrying the workspace's current AGENTS.md snapshot (combined content plus - * the oversize/load warning) and the change event fired when a watched - * instruction file invalidates the snapshot. The contract carries no IO. - * Session-scoped. + * carrying the workspace's current AGENTS.md snapshot (combined content, the + * oversize/load warning, and the discovered-file list) and the change event + * fired when a watched instruction file invalidates the snapshot. The + * contract carries no IO. Session-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -18,6 +18,7 @@ export interface ISessionInstructionsProvider { readonly ready: Promise; readonly agentsMd: string | undefined; readonly agentsMdWarning: string | undefined; + readonly agentsMdPaths: readonly string[] | undefined; readonly onDidChange: Event; } diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts index 00055dc4e7f..5315902f0df 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts @@ -20,6 +20,7 @@ import type { ISessionInstructionsProvider } from '#/session/sessionInstructions export interface WorkspaceInstructionsSnapshot { readonly agentsMd: string | undefined; readonly agentsMdWarning: string | undefined; + readonly agentsMdPaths: readonly string[] | undefined; } export interface IWorkspaceInstructionsService { diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts index 1b626854a24..99cb505c74d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts @@ -42,7 +42,7 @@ const WATCH_DEBOUNCE_MS = 200; export const workspaceInstructionsCurrentKey = defineState( 'workspaceInstructions.current', - () => ({ agentsMd: undefined, agentsMdWarning: undefined }), + () => ({ agentsMd: undefined, agentsMdWarning: undefined, agentsMdPaths: undefined }), ); export class WorkspaceInstructionsService @@ -94,12 +94,13 @@ export class WorkspaceInstructionsService const next: WorkspaceInstructionsSnapshot = { agentsMd: result.content, agentsMdWarning: result.warning, + agentsMdPaths: result.paths, }; - if ( + const changed = next.agentsMd !== this.current.agentsMd || - next.agentsMdWarning !== this.current.agentsMdWarning - ) { - this.current = next; + next.agentsMdWarning !== this.current.agentsMdWarning; + this.current = next; + if (changed) { this.onDidChangeEmitter.fire(); } }); @@ -110,6 +111,7 @@ export class WorkspaceInstructionsService sessionProvider(): ISessionInstructionsProvider { const currentAgentsMd = (): string | undefined => this.current.agentsMd; const currentWarning = (): string | undefined => this.current.agentsMdWarning; + const currentPaths = (): readonly string[] | undefined => this.current.agentsMdPaths; return { _serviceBrand: undefined, ready: this.ready, @@ -120,6 +122,9 @@ export class WorkspaceInstructionsService get agentsMdWarning() { return currentWarning(); }, + get agentsMdPaths() { + return currentPaths(); + }, }; } diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts new file mode 100644 index 00000000000..b9309eb3dbe --- /dev/null +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -0,0 +1,1179 @@ +/** + * Scenario: discover uninjected AGENTS.md files from canonical tool accesses and Bash targets. + * Responsibilities: seeding, once-only reminders, result delivery, probing, and path extraction. + * Wiring: real reminder, executor, parser, and host filesystem with telemetry/event stubs. + * Run: pnpm exec vitest run test/agent/agentsMdReminder/agentsMdReminder.test.ts + */ + +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, normalize } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { IBashParserService } from '#/app/bashParser/bashParser'; +import { BashParserService } from '#/app/bashParser/bashParserService'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ToolCall } from '#/kosong/contract/message'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { + ToolAccesses, + type ToolAccesses as ToolAccessesType, +} from '#/tool/toolContract'; +import type { + ExecutableTool, + ExecutableToolContext, + ExecutableToolResult, + ToolExecution, +} from '#/tool/toolContract'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; +import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; +import { ToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncationService'; +import { IEventBus } from '#/app/event/eventBus'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { AgentStateService } from '#/agent/state/agentStateService'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; +import { AgentToolDedupeService } from '#/agent/toolDedupe/toolDedupeService'; +import { OrderedHookSlot } from '#/hooks'; +import { IWireService } from '#/wire/wire'; +import type { + ResolvedToolExecutionHookContext, + ToolDidExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; +import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; +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'; +import { stubLoopWithHooks } from '../loop/stubs'; +import { registerLogServices } from '../../_base/log/stubs'; + +let disposables: DisposableStore; +let homeDir: string; +let workDir: string; + +beforeEach(async () => { + disposables = new DisposableStore(); + homeDir = await mkdtemp(join(tmpdir(), 'kimi-reminder-home-')); + workDir = await mkdtemp(join(tmpdir(), 'kimi-reminder-work-')); + await mkdir(join(workDir, '.git')); +}); + +afterEach(async () => { + disposables.dispose(); + await rm(homeDir, { recursive: true, force: true }); + await rm(workDir, { recursive: true, force: true }); +}); + +interface Harness { + readonly ix: TestInstantiationService; + readonly events: ToolExecutorEventStubs; + readonly reminder: IAgentAgentsMdReminderService; + readonly wire: IWireService; + readonly telemetryEvents: TelemetryRecord[]; +} + +function createHarness( + options: { + readonly withDedupe?: boolean; + readonly withRealExecutor?: boolean; + readonly telemetry?: ITelemetryService; + readonly cwd?: string; + readonly hostFs?: IHostFileSystem; + readonly pathClass?: 'posix' | 'win32'; + readonly restoredProfile?: { + readonly systemPrompt: string; + readonly agentsMdPaths?: readonly string[]; + }; + } = {}, +): Harness { + const telemetryEvents: TelemetryRecord[] = []; + const events = stubToolExecutorEvents(); + const ix = createServices(disposables, { + additionalServices: (reg) => { + if (options.withRealExecutor === true) { + reg.defineInstance(IEventBus, { + _serviceBrand: undefined, + publish: () => {}, + subscribe: () => ({ dispose: () => {} }), + }); + reg.define(IAgentToolRegistryService, AgentToolRegistryService); + reg.define(IAgentToolExecutorService, AgentToolExecutorService); + reg.defineInstance(IAgentScopeContext, { + _serviceBrand: undefined, + agentId: 'main', + scope: (sub?: string): string => (sub ? `agents/main/${sub}` : 'agents/main'), + } satisfies IAgentScopeContext); + reg.definePartialInstance(IFileSystemStorageService, { + write: async () => {}, + }); + reg.define(IAgentToolResultTruncationService, ToolResultTruncationService); + registerLogServices(reg); + } else { + reg.defineInstance(IAgentToolExecutorService, events.executor); + } + const wire: IWireService = { + _serviceBrand: undefined, + hooks: { onDidRestore: new OrderedHookSlot() }, + dispatch: () => {}, + seal: async () => {}, + restore: async () => {}, + flush: async () => {}, + getModel: () => + options.restoredProfile ?? { systemPrompt: '', agentsMdPaths: undefined }, + } as unknown as IWireService; + reg.defineInstance(IWireService, wire); + reg.defineInstance(IBootstrapService, { homeDir } as unknown as IBootstrapService); + reg.defineInstance(IAgentStateService, new AgentStateService()); + reg.defineInstance(ISessionContext, { + _serviceBrand: undefined, + sessionId: 'session-1', + workspaceId: 'workspace-1', + sessionDir: workDir, + metaScope: 'sessions/workspace-1/session-1', + cwd: options.cwd ?? workDir, + scope: (sub?: string): string => + sub ? `sessions/workspace-1/session-1/${sub}` : 'sessions/workspace-1/session-1', + } satisfies ISessionContext); + reg.defineInstance(IHostFileSystem, options.hostFs ?? new HostFileSystem()); + reg.defineInstance(IHostEnvironment, { + _serviceBrand: undefined, + homeDir, + pathClass: options.pathClass ?? 'posix', + } as unknown as IHostEnvironment); + reg.defineInstance(IBashParserService, new BashParserService()); + reg.defineInstance( + ITelemetryService, + options.telemetry ?? recordingTelemetry(telemetryEvents), + ); + if (options.withDedupe === true) { + reg.defineInstance(IAgentLoopService, stubLoopWithHooks()); + reg.define(IAgentToolDedupeService, AgentToolDedupeService); + } + reg.define(IAgentAgentsMdReminderService, AgentAgentsMdReminderService); + }, + strict: true, + }); + const reminder = ix.get(IAgentAgentsMdReminderService); + const wire = ix.get(IWireService); + return { ix, events, reminder, wire, telemetryEvents }; +} + +function didCtx( + name: string, + args: unknown, + options: { + readonly id?: string; + readonly result?: ExecutableToolResult; + readonly preflightRejected?: boolean; + readonly accesses?: ToolAccessesType; + } = {}, +): ToolDidExecuteContext { + const toolCall: ToolCall = { + type: 'function', + id: options.id ?? `call-${name}-1`, + name, + arguments: JSON.stringify(args), + }; + return { + turnId: 1, + signal: new AbortController().signal, + toolCall, + toolCalls: [toolCall], + args, + tool: options.preflightRejected === true ? undefined : ({} as ExecutableTool), + outcome: options.preflightRejected === true ? 'preflight-rejected' : 'executed', + accesses: + options.preflightRejected === true + ? undefined + : options.accesses ?? testAccesses(name, args), + result: options.result ?? { output: 'original result' }, + }; +} + +function testAccesses(name: string, args: unknown): ToolAccessesType | undefined { + if (typeof args !== 'object' || args === null) return undefined; + const path = (args as Record)['path']; + if (name === 'Read' || name === 'Edit' || name === 'Write') { + return typeof path === 'string' ? ToolAccesses.readFile(path) : undefined; + } + if (name === 'Glob' || name === 'Grep') { + return ToolAccesses.searchTree(typeof path === 'string' ? path : workDir); + } + return undefined; +} + +function willCtx(id: string, name: string, args: unknown): ResolvedToolExecutionHookContext { + const toolCall: ToolCall = { + type: 'function', + id, + name, + arguments: JSON.stringify(args), + }; + return { + turnId: 1, + signal: new AbortController().signal, + toolCall, + toolCalls: [toolCall], + args, + execution: { approvalRule: 'x', execute: async () => ({ output: '' }) }, + }; +} + +async function fire(h: Harness, ctx: ToolDidExecuteContext): Promise { + await h.events.didExecuteSlot.run(ctx); + return ctx.result; +} + +function outputText(result: ExecutableToolResult): string { + const output = result.output; + if (typeof output === 'string') return output; + return output + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join(''); +} + +async function writeAgentsMd(dir: string, content = 'instructions'): Promise { + await mkdir(dir, { recursive: true }); + const path = join(dir, 'AGENTS.md'); + await writeFile(path, content, 'utf-8'); + return normalize(path); +} + +describe('agentsMdReminder path-carrying tools', () => { + it('appends a reminder listing the uninjected AGENTS.md when Read touches its directory', async () => { + const h = createHarness(); + const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir, 'package instructions'); + h.reminder.seedInjected([rootAgentsMd], workDir); + + const result = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); + + const text = outputText(result); + expect(text).toContain('original result'); + expect(text).toContain(''); + expect(text).toContain(subAgentsMd); + expect(text).not.toContain(rootAgentsMd); + }); + + it('reminds at most once per file', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + + const first = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); + const second = await fire(h, didCtx('Edit', { path: join(subDir, 'b.ts') })); + + expect(outputText(first)).toContain(subAgentsMd); + expect(outputText(second)).not.toContain(''); + }); + + it('marks an AGENTS.md known when read directly and never suggests it afterwards', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + + const direct = await fire(h, didCtx('Read', { path: subAgentsMd })); + expect(outputText(direct)).not.toContain(''); + + const after = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); + expect(outputText(after)).not.toContain(subAgentsMd); + }); + + it('discovers the .kimi-code/AGENTS.md variant alongside the plain one', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const dotKimi = normalize(join(subDir, '.kimi-code', 'AGENTS.md')); + await writeAgentsMd(join(subDir, '.kimi-code'), 'dot kimi instructions'); + const plain = await writeAgentsMd(subDir); + + const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + + const text = outputText(result); + expect(text).toContain(dotKimi); + expect(text).toContain(plain); + }); + + it('anchors at the nearest existing ancestor when Write targets a not-yet-created directory', async () => { + const h = createHarness(); + const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); + // The root file was created after the bind injected nothing. + h.reminder.seedInjected([], workDir); + + const result = await fire( + h, + didCtx('Write', { path: join(workDir, 'new-pkg', 'src', 'index.ts'), content: 'x' }), + ); + + expect(outputText(result)).toContain(rootAgentsMd); + }); + + it('does not remind for seeded paths on the injected chain', async () => { + const h = createHarness(); + const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); + h.reminder.seedInjected([rootAgentsMd], workDir); + + const result = await fire(h, didCtx('Glob', { pattern: '**/*.ts' })); + + expect(outputText(result)).not.toContain(''); + }); + + it('tracks the shown event through telemetry', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await writeAgentsMd(subDir); + + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + + expect(h.telemetryEvents).toHaveLength(1); + expect(h.telemetryEvents[0]!.event).toBe('agents_md_reminder_shown'); + expect(h.telemetryEvents[0]!.properties).toMatchObject({ + turn_id: 1, + tool_name: 'Read', + reminded_count: 1, + }); + }); +}); + +describe('agentsMdReminder Bash coverage', () => { + it('reminds for the directory listed by a plain ls', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire(h, didCtx('Bash', { command: 'ls packages/kap-server' })); + + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('rebases relative operands across a literal cd', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire(h, didCtx('Bash', { command: 'cd packages && ls kap-server' })); + + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('extracts find roots and stops at the expression', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire( + h, + didCtx('Bash', { command: "find packages/kap-server -name '*.ts'" }), + ); + + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('extracts quoted directory operands', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire(h, didCtx('Bash', { command: 'ls "packages/kap-server"' })); + + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('probes an explicit cwd even when the command lists nothing', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire( + h, + didCtx('Bash', { command: 'git status', cwd: 'packages/kap-server' }), + ); + + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('skips operands that are not statically resolvable', async () => { + const h = createHarness(); + await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + for (const command of ['ls $DIR', 'ls *.ts', 'ls $(pwd)', 'echo packages/kap-server']) { + const result = await fire(h, didCtx('Bash', { command })); + expect(outputText(result)).not.toContain(''); + } + }); +}); + +describe('agentsMdReminder result shapes and edge cases', () => { + it('prepends the reminder to the first text part of ContentPart[] outputs', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire( + h, + didCtx( + 'Read', + { path: join(workDir, 'packages', 'kap-server', 'index.ts') }, + { result: { output: [{ type: 'text', text: 'part one' }] } }, + ), + ); + + expect(Array.isArray(result.output)).toBe(true); + expect(outputText(result).startsWith('')).toBe(true); + expect(outputText(result)).toContain('part one'); + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('does not mark an AGENTS.md known when the direct read failed', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const agentsMdPath = normalize(join(subDir, 'AGENTS.md')); + + const failed = await fire( + h, + didCtx('Read', { path: agentsMdPath }, { result: { output: 'not found', isError: true } }), + ); + expect(outputText(failed)).not.toContain(''); + + await writeAgentsMd(subDir); + const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(outputText(after)).toContain(agentsMdPath); + }); +}); + +describe('agentsMdReminder toolDedupe interplay', () => { + it('delivers the reminder through a same-step duplicate resolved by toolDedupe', async () => { + const h = createHarness({ withDedupe: true }); + h.ix.get(IAgentToolDedupeService); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + const args = { path: join(workDir, 'packages', 'kap-server', 'index.ts') }; + + await h.events.fireBeforeExecute(willCtx('call-1', 'Read', args)); + const did1 = didCtx('Read', args, { id: 'call-1' }); + await h.events.didExecuteSlot.run(did1); + expect(outputText(did1.result)).toContain(subAgentsMd); + + const decision = await h.events.fireBeforeExecute(willCtx('call-2', 'Read', args)); + const did2 = didCtx('Read', args, { + id: 'call-2', + result: decision?.veto ?? { output: '' }, + }); + await h.events.didExecuteSlot.run(did2); + expect(outputText(did2.result)).toContain(subAgentsMd); + }); + + it('leaves the vetoed placeholder untouched and reminds exactly once on the visible results', async () => { + const h = createHarness({ withRealExecutor: true, withDedupe: true }); + h.ix.get(IAgentToolDedupeService); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + class ReadTool implements ExecutableTool> { + readonly name = 'Read'; + readonly description = 'Returns file contents.'; + readonly parameters = { type: 'object', additionalProperties: true }; + resolveExecution(args: Record): ToolExecution { + return { + accesses: ToolAccesses.readFile(String(args['path'])), + approvalRule: this.name, + execute: async (_ctx: ExecutableToolContext) => ({ output: 'file contents' }), + }; + } + } + h.ix.get(IAgentToolRegistryService).register(new ReadTool()); + + const args = { path: join(workDir, 'packages', 'kap-server', 'index.ts') }; + const calls: ToolCall[] = [ + { type: 'function', id: 'call-1', name: 'Read', arguments: JSON.stringify(args) }, + { type: 'function', id: 'call-2', name: 'Read', arguments: JSON.stringify(args) }, + ]; + const results = []; + for await (const item of h.ix + .get(IAgentToolExecutorService) + .execute(calls, { turnId: 1, signal: new AbortController().signal })) { + results.push(item); + } + + expect(results).toHaveLength(2); + for (const item of results) { + const text = outputText(item.result); + expect(text).toContain('file contents'); + expect(text).toContain(subAgentsMd); + } + const shown = h.telemetryEvents.filter((e) => e.event === 'agents_md_reminder_shown'); + expect(shown).toHaveLength(1); + }); +}); + +describe('agentsMdReminder lazy seeding after a restore', () => { + it('self-seeds the injected chain on the first touch when no seed point ever fired', async () => { + const h = createHarness(); + const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire( + h, + didCtx('Read', { path: join(workDir, 'packages', 'kap-server', 'index.ts') }), + ); + + const text = outputText(result); + expect(text).toContain(subAgentsMd); + expect(text).not.toContain(rootAgentsMd); + }); + + it('treats the brand-home AGENTS.md as injected after a restore', async () => { + const h = createHarness(); + const brandAgentsMd = await writeAgentsMd(homeDir, 'brand instructions'); + + const result = await fire(h, didCtx('Read', { path: join(homeDir, 'notes.txt') })); + + expect(outputText(result)).toBe('original result'); + expect(outputText(result)).not.toContain(brandAgentsMd); + expect(h.telemetryEvents).toHaveLength(0); + }); +}); + +describe('agentsMdReminder persisted restore provenance', () => { + it('keeps a newly created instruction path eligible after restoring persisted paths', async () => { + const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir, 'package instructions'); + const h = createHarness({ + restoredProfile: { + systemPrompt: `\nroot instructions`, + agentsMdPaths: [rootAgentsMd], + }, + }); + + await h.wire.hooks.onDidRestore.run({}); + const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('recovers injected paths from a legacy restored prompt without path provenance', async () => { + const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); + const h = createHarness({ + restoredProfile: { + systemPrompt: `\nroot instructions`, + }, + }); + + await h.wire.hooks.onDidRestore.run({}); + const result = await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); + + expect(outputText(result)).not.toContain(''); + }); +}); + +describe('agentsMdReminder Bash operand hygiene', () => { + it('does not treat option arguments as directories', async () => { + const h = createHarness(); + const eighty = await writeAgentsMd(join(workDir, '80'), 'eighty instructions'); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire(h, didCtx('Bash', { command: 'ls -w 80 packages/kap-server' })); + + const text = outputText(result); + expect(text).toContain(subAgentsMd); + expect(text).not.toContain(eighty); + }); + + it('collects find roots past its global options', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire( + h, + didCtx('Bash', { command: "find -L packages/kap-server -name '*.ts'" }), + ); + + expect(outputText(result)).toContain(subAgentsMd); + }); +}); + +describe('agentsMdReminder probing boundaries', () => { + it('ignores an empty AGENTS.md just like the init-time load', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await mkdir(subDir, { recursive: true }); + await writeFile(join(subDir, 'AGENTS.md'), '', 'utf-8'); + + const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + + expect(outputText(result)).not.toContain(''); + }); + + it('still reminds when the triggering call ended in an error result', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + + const result = await fire( + h, + didCtx('Read', { path: join(subDir, 'missing.ts') }, { + result: { output: 'not found', isError: true }, + }), + ); + + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('marks an AGENTS.md known when it is written directly', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await mkdir(subDir, { recursive: true }); + const agentsMdPath = normalize(join(subDir, 'AGENTS.md')); + + const written = await fire(h, didCtx('Write', { path: agentsMdPath, content: 'x' })); + expect(outputText(written)).not.toContain(''); + + const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(outputText(after)).not.toContain(agentsMdPath); + }); + + it('reminds at most once for two parallel touches of the same directory', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await writeAgentsMd(subDir); + + const [first, second] = await Promise.all([ + fire(h, didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-a' })), + fire(h, didCtx('Read', { path: join(subDir, 'b.ts') }, { id: 'call-b' })), + ]); + + const reminders = [first, second].filter((result) => + outputText(result).includes(''), + ); + expect(reminders).toHaveLength(1); + }); + + it('re-judges the project root at a nested repository', async () => { + const h = createHarness(); + const nested = join(workDir, 'packages', 'nested'); + await mkdir(join(nested, '.git'), { recursive: true }); + const rootAgentsMd = await writeAgentsMd(workDir, 'outer instructions'); + const nestedAgentsMd = await writeAgentsMd(nested, 'nested instructions'); + + const result = await fire(h, didCtx('Read', { path: join(nested, 'index.ts') })); + + const text = outputText(result); + expect(text).toContain(nestedAgentsMd); + expect(text).not.toContain(rootAgentsMd); + }); + + it('probes only the immediate directory outside any project', async () => { + const h = createHarness(); + const outside = await mkdtemp(join(tmpdir(), 'kimi-reminder-outside-')); + const outerAgentsMd = await writeAgentsMd(outside, 'outer instructions'); + const leaf = join(outside, 'leaf'); + const leafAgentsMd = await writeAgentsMd(leaf, 'leaf instructions'); + + try { + const result = await fire(h, didCtx('Read', { path: join(leaf, 'index.ts') })); + + const text = outputText(result); + expect(text).toContain(leafAgentsMd); + expect(text).not.toContain(outerAgentsMd); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('discovers a symlinked directory through the link at its lexical address', async () => { + const h = createHarness(); + const target = await mkdtemp(join(tmpdir(), 'kimi-reminder-target-')); + const targetAgentsMd = await writeAgentsMd(target, 'target instructions'); + await symlink(target, join(workDir, 'link')); + + try { + const result = await fire(h, didCtx('Read', { path: join(workDir, 'link', 'index.ts') })); + + const text = outputText(result); + expect(text).toContain(normalize(join(workDir, 'link', 'AGENTS.md'))); + expect(text).not.toContain(targetAgentsMd); + } finally { + await rm(target, { recursive: true, force: true }); + } + }); +}); + +describe('agentsMdReminder round-2 hardening', () => { + it('skips preflight-rejected calls entirely (no probing behind the path policy)', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await writeAgentsMd(subDir); + + const result = await fire( + h, + didCtx('Read', { path: join(subDir, 'index.ts') }, { preflightRejected: true }), + ); + + expect(outputText(result)).toBe('original result'); + expect(h.telemetryEvents).toHaveLength(0); + }); + + it('resolves Bash targets against the frozen session cwd, not the seeded live cwd', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages')); + const liveCwd = join(workDir, 'elsewhere'); + h.reminder.seedInjected([], liveCwd); + + const result = await fire(h, didCtx('Bash', { command: 'true' })); + + expect(outputText(result)).toBe('original result'); + + const listed = await fire(h, didCtx('Bash', { command: 'ls packages' })); + expect(outputText(listed)).toContain(subAgentsMd); + }); + + it('ignores a whitespace-only AGENTS.md just like the init-time load', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await mkdir(subDir, { recursive: true }); + await writeFile(join(subDir, 'AGENTS.md'), ' \n\t \n', 'utf-8'); + + const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + + expect(outputText(result)).not.toContain(''); + }); + + it('keeps known-sets isolated between agents', async () => { + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + const first = createHarness(); + const second = createHarness(); + + const firstResult = await fire(first, didCtx('Read', { path: join(subDir, 'index.ts') })); + const secondResult = await fire(second, didCtx('Read', { path: join(subDir, 'index.ts') })); + + expect(outputText(firstResult)).toContain(subAgentsMd); + expect(outputText(secondResult)).toContain(subAgentsMd); + }); + + it('releases the claim when attaching the reminder fails, so the next touch retries', async () => { + let shouldThrow = true; + const telemetry = { + ...recordingTelemetry([]), + track2: (event: string, properties?: unknown) => { + if (shouldThrow) throw new Error('telemetry boom'); + }, + } satisfies ITelemetryService; + const h = createHarness({ telemetry }); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + + const failed = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); + expect(outputText(failed)).toBe('original result'); + + shouldThrow = false; + const retried = await fire(h, didCtx('Read', { path: join(subDir, 'b.ts') })); + expect(outputText(retried)).toContain(subAgentsMd); + }); + + it('prepends the reminder so it survives head-only truncation', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire( + h, + didCtx('Read', { path: join(workDir, 'packages', 'kap-server', 'index.ts') }), + ); + + expect(outputText(result).startsWith('')).toBe(true); + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('survives the real executor pipeline with oversized results', async () => { + const h = createHarness({ withRealExecutor: true }); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + class BigTool implements ExecutableTool> { + readonly name = 'Read'; + readonly description = 'Returns a huge output.'; + readonly parameters = { type: 'object', additionalProperties: true }; + resolveExecution(args: Record): ToolExecution { + return { + accesses: ToolAccesses.readFile(String(args['path'])), + approvalRule: this.name, + execute: async (_ctx: ExecutableToolContext) => ({ output: 'x'.repeat(60_000) }), + }; + } + } + h.ix.get(IAgentToolRegistryService).register(new BigTool()); + + const toolCall: ToolCall = { + type: 'function', + id: 'call-big-1', + name: 'Read', + arguments: JSON.stringify({ path: join(workDir, 'packages', 'kap-server', 'big.ts') }), + }; + const results = []; + for await (const item of h.ix + .get(IAgentToolExecutorService) + .execute([toolCall], { turnId: 1, signal: new AbortController().signal })) { + results.push(item); + } + + expect(results).toHaveLength(1); + const output = results[0]!.result.output; + expect(typeof output).toBe('string'); + const text = output as string; + expect(text).toContain('output_path:'); + expect(text.indexOf('')).toBeLessThan(2_000); + expect(text).toContain(subAgentsMd); + }); + + it('uses the resolved file access instead of reparsing the raw path', async () => { + const h = createHarness({ withRealExecutor: true }); + const homePackage = join(homeDir, 'pkg'); + const homeAgentsMd = await writeAgentsMd(homePackage, 'home package instructions'); + + class ResolvedReadTool implements ExecutableTool> { + readonly name = 'Read'; + readonly description = 'Returns a resolved file result.'; + readonly parameters = { type: 'object', additionalProperties: true }; + + resolveExecution(_args: Record): ToolExecution { + return { + accesses: ToolAccesses.readFile(join(homePackage, 'index.ts')), + approvalRule: this.name, + execute: async (_ctx: ExecutableToolContext) => ({ output: 'home file contents' }), + }; + } + } + h.ix.get(IAgentToolRegistryService).register(new ResolvedReadTool()); + + const results = []; + for await (const item of h.ix.get(IAgentToolExecutorService).execute( + [ + { + type: 'function', + id: 'call-resolved-read', + name: 'Read', + arguments: JSON.stringify({ path: '~/pkg/index.ts' }), + }, + ], + { turnId: 1, signal: new AbortController().signal }, + )) { + results.push(item); + } + + expect(results).toHaveLength(1); + expect(outputText(results[0]!.result)).toContain(homeAgentsMd); + }); + + it('does not probe or remind when permission vetoes an access-bearing call', async () => { + const h = createHarness({ withRealExecutor: true }); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + const hostFs = h.ix.get(IHostFileSystem); + const stat = vi.spyOn(hostFs, 'stat'); + const readText = vi.spyOn(hostFs, 'readText'); + + class ReadTool implements ExecutableTool> { + readonly name = 'Read'; + readonly description = 'Returns file contents.'; + readonly parameters = { type: 'object', additionalProperties: true }; + + resolveExecution(_args: Record): ToolExecution { + return { + accesses: ToolAccesses.readFile(join(subDir, 'index.ts')), + approvalRule: this.name, + execute: async () => { + throw new Error('vetoed tool must not execute'); + }, + }; + } + } + h.ix.get(IAgentToolRegistryService).register(new ReadTool()); + h.ix.get(IAgentToolExecutorService).onBeforeExecuteTool((event) => { + event.veto({ output: 'permission denied', isError: true }); + }); + + const results = []; + for await (const item of h.ix.get(IAgentToolExecutorService).execute( + [ + { + type: 'function', + id: 'call-denied-read', + name: 'Read', + arguments: JSON.stringify({ path: join(subDir, 'index.ts') }), + }, + ], + { turnId: 1, signal: new AbortController().signal }, + )) { + results.push(item); + } + + expect(results).toHaveLength(1); + expect(outputText(results[0]!.result)).toBe('permission denied'); + expect(outputText(results[0]!.result)).not.toContain(subAgentsMd); + expect(stat).not.toHaveBeenCalled(); + expect(readText).not.toHaveBeenCalled(); + expect( + h.telemetryEvents.filter((event) => event.event === 'agents_md_reminder_shown'), + ).toHaveLength(0); + }); +}); + +describe('agentsMdReminder cancellation outcomes', () => { + it('does not consume a reminder for a conflicting task cancelled before execution starts', async () => { + const h = createHarness({ withRealExecutor: true }); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + + class BlockingBash implements ExecutableTool> { + readonly name = 'Bash'; + readonly description = 'Blocks until cancelled.'; + readonly parameters = { type: 'object', additionalProperties: true }; + + resolveExecution(): ToolExecution { + return { + accesses: ToolAccesses.all(), + approvalRule: this.name, + execute: ({ signal }) => { + resolveStarted(); + return new Promise((resolve) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort); + resolve({ output: 'bash aborted', isError: true }); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort); + }); + }, + }; + } + } + + class ReadTool implements ExecutableTool> { + readonly name = 'Read'; + readonly description = 'Reads a file.'; + readonly parameters = { type: 'object', additionalProperties: true }; + + resolveExecution(): ToolExecution { + return { + accesses: ToolAccesses.readFile(join(subDir, 'index.ts')), + approvalRule: this.name, + execute: async () => ({ output: 'read result' }), + }; + } + } + + h.ix.get(IAgentToolRegistryService).register(new BlockingBash()); + h.ix.get(IAgentToolRegistryService).register(new ReadTool()); + const controller = new AbortController(); + const calls: ToolCall[] = [ + { + type: 'function', + id: 'call-blocking-bash', + name: 'Bash', + arguments: JSON.stringify({ command: 'sleep 60' }), + }, + { + type: 'function', + id: 'call-queued-read', + name: 'Read', + arguments: JSON.stringify({ path: join(subDir, 'index.ts') }), + }, + ]; + const pending = (async () => { + const results = []; + for await (const item of h.ix.get(IAgentToolExecutorService).execute(calls, { + turnId: 1, + signal: controller.signal, + })) { + results.push(item); + } + return results; + })(); + + await started; + controller.abort(); + const results = await pending; + const queued = results.find((item) => item.toolCallId === 'call-queued-read'); + expect(queued).toBeDefined(); + expect(outputText(queued!.result)).not.toContain(''); + expect( + h.telemetryEvents.filter((event) => event.event === 'agents_md_reminder_shown'), + ).toEqual([]); + + const real = []; + for await (const item of h.ix.get(IAgentToolExecutorService).execute( + [ + { + type: 'function', + id: 'call-real-read', + name: 'Read', + arguments: JSON.stringify({ path: join(subDir, 'index.ts') }), + }, + ], + { + turnId: 2, + signal: new AbortController().signal, + }, + )) { + real.push(item); + } + expect(outputText(real[0]!.result)).toContain(subAgentsMd); + }); +}); + +describe('agentsMdReminder Bash parse degradation', () => { + it('falls back to the structured cwd argument when the command cannot be parsed', async () => { + const h = createHarness(); + const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire( + h, + didCtx('Bash', { command: "ls '", cwd: 'packages/kap-server' }), + ); + + expect(outputText(result)).toContain(subAgentsMd); + }); + + it('skips entirely when an unparseable command has no explicit cwd', async () => { + const h = createHarness(); + await writeAgentsMd(join(workDir, 'packages', 'kap-server')); + + const result = await fire(h, didCtx('Bash', { command: "ls '" })); + + expect(outputText(result)).toBe('original result'); + }); +}); + +describe('agentsMdReminder Windows Bash paths', () => { + function windowsProbeFs( + targetDir: string, + agentsMdPath: string, + projectRoot: string, + ): IHostFileSystem { + const directory: HostFileStat = { + isFile: false, + isDirectory: true, + size: 0, + }; + const stat = vi.fn(async (path: string): Promise => { + if (path === targetDir || path === join(projectRoot, '.git')) return directory; + throw new Error(`missing: ${path}`); + }); + const readText = vi.fn(async (path: string): Promise => { + if (path === agentsMdPath) return 'windows instructions'; + throw new Error(`missing: ${path}`); + }); + return { stat, readText } as unknown as IHostFileSystem; + } + + it('converts Git Bash drive paths before probing the host filesystem', async () => { + const projectRoot = 'C:/repo'; + const targetDir = `${projectRoot}/packages/app`; + const agentsMdPath = `${targetDir}/AGENTS.md`; + + for (const args of [ + { command: 'ls /cygdrive/c/repo/packages/app' }, + { command: 'true', cwd: '/c/repo/packages/app' }, + ]) { + const h = createHarness({ + cwd: projectRoot, + hostFs: windowsProbeFs(targetDir, agentsMdPath, projectRoot), + pathClass: 'win32', + }); + h.reminder.seedInjected([], projectRoot); + + const result = await fire(h, didCtx('Bash', args)); + + expect(outputText(result)).toContain(agentsMdPath); + } + }); +}); + +describe('extractBashTargetDirs', () => { + const parser = new BashParserService(); + + function targets(command: string, cwd = workDir): string[] { + const parsed = parser.parse(command); + if (!parsed.ok) throw new Error(`parse aborted for: ${command}`); + return extractBashTargetDirs(parsed.root, cwd, homeDir); + } + + it('resolves operands of listing commands against the cwd', () => { + expect(targets('ls packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('tree /opt /srv')).toEqual(['/opt', '/srv']); + }); + + it('tracks cd chains, the bare-cd home fallback, and operand-less listings', () => { + expect(targets('cd packages && cd kap-server && ls')).toEqual([ + normalize(join(workDir, 'packages', 'kap-server')), + ]); + expect(targets('cd packages && ls ../docs')).toEqual([normalize(join(workDir, 'docs'))]); + expect(targets('cd && ls notes')).toEqual([normalize(join(homeDir, 'notes'))]); + expect(targets('ls')).toEqual([workDir]); + expect(targets('find')).toEqual([workDir]); + }); + + it('poisons relative resolution after an unresolvable cd instead of guessing a base', () => { + expect(targets('cd $DIR && ls packages')).toEqual([]); + expect(targets('cd ~/packages && ls src')).toEqual([]); + expect(targets('cd - && ls packages')).toEqual([]); + expect(targets('cd $X && cd /opt && ls x')).toEqual(['/opt/x']); + }); + + it('skips listing commands invoked through a path prefix', () => { + expect(targets('/bin/ls packages')).toEqual([]); + expect(targets('./ls packages')).toEqual([]); + expect(targets('../tools/find packages')).toEqual([]); + }); + + it('ignores pipelines of non-listing commands and compound constructs', () => { + expect(targets('cat packages | grep foo')).toEqual([]); + expect(targets('if [ -f x ]; then ls packages; fi')).toEqual([]); + expect(targets('(cd packages && ls)')).toEqual([]); + }); + + it('drops the arguments of known argument-taking options', () => { + expect(targets('ls -w 80 packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('ls --sort size packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('ls --sort=size packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('ls -L packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('ls -P packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('ls -o packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('ls -s packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('tree -L 2 packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('find -L packages -name x')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('find -H -P /srv -type f')).toEqual(['/srv']); + expect(targets('find -- packages -name x')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('ls -- packages')).toEqual([normalize(join(workDir, 'packages'))]); + }); + + it('skips words whose escapes would introduce glob characters', () => { + expect(targets('ls foo\\*bar')).toEqual([]); + }); + + it('does not rebase operands on a cd inside a pipeline', () => { + expect(targets('cd /tmp | ls packages')).toEqual([normalize(join(workDir, 'packages'))]); + }); + + it('skips quoted glob operands as well', () => { + expect(targets("ls '*.ts'")).toEqual([]); + expect(targets('ls "*.ts"')).toEqual([]); + }); + + it('handles assignment prefixes and redirects, and skips wrappers', () => { + expect(targets('FOO=bar ls packages')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('ls packages > out.txt')).toEqual([normalize(join(workDir, 'packages'))]); + expect(targets('sudo ls packages')).toEqual([]); + expect(targets('! ls packages')).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 8f5ecb81f89..571aaf31c14 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -258,6 +258,7 @@ async function runTerminalUpdateGoalResult( toolCall, toolCalls: [toolCall], args: { status }, + outcome: 'executed', result: { output, stopTurn: true }, }); } diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 3b78e42048d..6ed728cef2c 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'pathe'; +import { join, normalize } from 'pathe'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -13,6 +13,7 @@ import { BuiltinAgentProfileLoaderService } from '#/app/agentProfileCatalog/buil import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution'; import type { ToolCall } from '#/kosong/contract/message'; import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile/profile'; +import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; @@ -27,6 +28,7 @@ import type { ExecutableTool, ToolExecution, ToolResult, ToolSource } from '#/to import { InMemoryWireRecordPersistence, + agentService, appService, createTestAgent, hostEnvironmentServices, @@ -166,7 +168,8 @@ describe('AgentProfileService.bind', () => { }); await ctx.get(IWireService).flush(); - expect(persistence.records.find((record) => record.type === 'profile.bind')).toMatchObject({ + const bindingRecord = persistence.records.find((record) => record.type === 'profile.bind'); + expect(bindingRecord).toMatchObject({ profileName: 'delegates-explore', subagents: ['explore'], }); @@ -197,6 +200,9 @@ describe('AgentProfileService.bind', () => { profileName: 'delegates-explore', subagents: ['explore'], }); + expect(ctx.get(IAgentProfileService).data().agentsMdPaths).toEqual( + bindingRecord?.['agentsMdPaths'], + ); }); it('refreshes the system prompt from the session cwd after a default bind', async () => { @@ -1067,3 +1073,67 @@ class PolicyProbeTool implements ExecutableTool> { }; } } + +describe('agentsMdReminder seeding', () => { + let ctx: TestAgentContext; + let homeDir: string; + let workDir: string; + + beforeAll(() => { + registerAgentProfile({ + name: 'throws-on-prompt', + systemPrompt: () => { + throw new Error('prompt build boom'); + }, + }); + }); + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-seed-home-')); + workDir = await mkdtemp(join(tmpdir(), 'kimi-seed-work-')); + }); + + afterEach(async () => { + await ctx?.dispose(); + await rm(homeDir, { recursive: true, force: true }); + await rm(workDir, { recursive: true, force: true }); + }); + + function buildSeededContext( + seedInjected: IAgentAgentsMdReminderService['seedInjected'], + ): IAgentProfileService { + ctx = createTestAgent( + { cwd: workDir }, + hostEnvironmentServices(homeDir), + agentService(IAgentAgentsMdReminderService, { + _serviceBrand: undefined, + seedInjected, + }), + ); + return ctx.get(IAgentProfileService); + } + + it('seeds the known-set with the injected paths after a successful bind', async () => { + const seedInjected = vi.fn<(paths: readonly string[], cwd: string) => void>(); + const profile = buildSeededContext(seedInjected); + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8'); + + seedInjected.mockClear(); + await profile.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + + expect(seedInjected).toHaveBeenCalledWith([normalize(join(workDir, 'AGENTS.md'))], workDir); + expect(profile.data().agentsMdPaths).toEqual([normalize(join(workDir, 'AGENTS.md'))]); + }); + + it('does not seed when the prompt build fails before the bind commits', async () => { + const seedInjected = vi.fn<(paths: readonly string[], cwd: string) => void>(); + const profile = buildSeededContext(seedInjected); + + seedInjected.mockClear(); + await expect(profile.bind({ profile: 'throws-on-prompt', model: MOCK_MODEL })).rejects.toThrow( + 'prompt build boom', + ); + + expect(seedInjected).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-core-v2/test/agent/profile/context.test.ts b/packages/agent-core-v2/test/agent/profile/context.test.ts index 802ed1d552a..035a6f0c35b 100644 --- a/packages/agent-core-v2/test/agent/profile/context.test.ts +++ b/packages/agent-core-v2/test/agent/profile/context.test.ts @@ -1,12 +1,17 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'pathe'; +import { join, normalize } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { loadAgentsMd, prepareSystemPromptContext } from '#/agent/profile/context'; +import { + extractAgentsMdPathsFromSystemPrompt, + loadAgentsMd, + loadAgentsMdDetailed, + prepareSystemPromptContext, +} from '#/agent/profile/context'; function createFs(): IHostFileSystem { return new HostFileSystem(); @@ -260,3 +265,46 @@ describe('prepareSystemPromptContext additional directories', () => { expect(agentsMd).not.toContain('extra B instructions'); }); }); + +describe('loadAgentsMdDetailed discovered paths', () => { + it('recovers AGENTS.md source annotations without treating plugin annotations as files', () => { + expect( + extractAgentsMdPathsFromSystemPrompt( + '\nroot\n\n\nplugin', + ), + ).toEqual(['/repo/AGENTS.md']); + }); + + it('returns the normalized paths of every injected file in collection order', async () => { + await mkdir(join(homeDir, '.kimi-code'), { recursive: true }); + await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'user branded', 'utf-8'); + await mkdir(join(workDir, '.kimi-code'), { recursive: true }); + await writeFile(join(workDir, '.kimi-code', 'AGENTS.md'), 'dot kimi', 'utf-8'); + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8'); + + const result = await loadAgentsMdDetailed({ fs, homeDir }, workDir); + + expect(result.paths).toEqual([ + normalize(join(homeDir, '.kimi-code', 'AGENTS.md')), + normalize(join(workDir, '.kimi-code', 'AGENTS.md')), + normalize(join(workDir, 'AGENTS.md')), + ]); + }); + + it('prefers AGENTS.md over agents.md within one directory', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'upper', 'utf-8'); + await writeFile(join(workDir, 'agents.md'), 'lower', 'utf-8'); + + const result = await loadAgentsMdDetailed({ fs, homeDir }, workDir); + + expect(result.paths).toEqual([normalize(join(workDir, 'AGENTS.md'))]); + }); + + it('exposes the same paths through prepareSystemPromptContext', async () => { + await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8'); + + const result = await prepareSystemPromptContext({ fs, homeDir }, workDir); + + expect(result.agentsMdPaths).toEqual([normalize(join(workDir, 'AGENTS.md'))]); + }); +}); 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 290d5c269b0..b990b1f9ab5 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -7,6 +7,7 @@ import { Event } from '#/_base/event'; import { IAgentProfileService } from '#/agent/profile/profile'; import { AgentProfileService } from '#/agent/profile/profileService'; import { ActiveToolsModel, ProfileModel } from '#/agent/profile/profileOps'; +import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -225,8 +226,13 @@ function buildHost(key: string): { ready: Promise.resolve(), agentsMd: undefined, agentsMdWarning: undefined, + agentsMdPaths: undefined, onDidChange: Event.None as Event, } satisfies ISessionInstructionsProvider); + host.stub(IAgentAgentsMdReminderService, { + _serviceBrand: undefined, + seedInjected: () => {}, + }); host.stub(ISessionToolPolicy, { _serviceBrand: undefined, ready: Promise.resolve(), 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 2035a0e4618..4e1c5325c6c 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -251,6 +251,7 @@ function didCtx( toolCall: tc, toolCalls: [tc], args, + outcome: 'executed', result, }; } diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index f0842a968c4..4c9b68f4607 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -16,7 +16,10 @@ import { type ToolUpdate, } from '#/tool/toolContract'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import type { BeforeToolExecuteEvent } from '#/agent/toolExecutor/toolHooks'; +import type { + BeforeToolExecuteEvent, + ToolExecutionOutcome, +} from '#/agent/toolExecutor/toolHooks'; import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; @@ -588,8 +591,13 @@ describe('AgentToolExecutorService', () => { const controller = new AbortController(); const first = new ControlledTool('first', ToolAccesses.writeFile('/repo/a.ts')); const second = new ControlledTool('second', ToolAccesses.writeFile('/repo/a.ts')); + const outcomes = new Map(); registry.register(first); registry.register(second); + executor.hooks.onDidExecuteTool.register('capture-outcomes', async (ctx, next) => { + outcomes.set(ctx.toolCall.id, ctx.outcome); + await next(); + }); const execution = execute( [toolCall('call_first', 'first', {}), toolCall('call_second', 'second', {})], @@ -601,6 +609,12 @@ describe('AgentToolExecutorService', () => { expect(first.calls).toHaveLength(1); expect(second.calls).toHaveLength(0); + expect(outcomes).toEqual( + new Map([ + ['call_first', 'executed'], + ['call_second', 'aborted'], + ]), + ); expect(results).toEqual([ expect.objectContaining({ output: 'Tool "first" was aborted', isError: true }), expect.objectContaining({ output: 'Tool "second" was aborted', isError: true }), diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index cf201ee0bfa..cc49a9a0d99 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -198,7 +198,7 @@ describe('Agent config', () => { }); expect(ctx.newEvents()).toMatchInlineSnapshot(` - [wire] config.update { "profileName": "test-profile", "systemPrompt": "Profile system prompt.", "disallowedTools": [], "time": "